diff --git a/CHANGELOG.md b/CHANGELOG.md index 06ef835..b45483f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +# 0.1.3 + + * Fix bold toggle + * Improvements to preview size in search results + * Make URLs highlighted and clickable (Ctrl+click) + * Explain the purpose of the encryption key for first-time use + * Support saving the encryption key to the settings file to avoid being prompted (off by default) + * Abbreviated toolbar symbols to keep things tidier. Add tooltips + # 0.1.2 * Switch from Markdown to HTML via QTextEdit, with a toolbar diff --git a/README.md b/README.md index 3307543..94d0648 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a dr for SQLite3. This means that the underlying database for the notebook is encrypted at rest. To increase security, the SQLCipher key is requested when the app is opened, and is not written -to disk. +to disk unless the user configures it to be in the settings. There is deliberately no network connectivity or syncing intended. @@ -35,6 +35,8 @@ There is deliberately no network connectivity or syncing intended. ## How to install +Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions). + ### From source * Clone this repo or download the tarball from the releases page diff --git a/bouquin/db.py b/bouquin/db.py index c75847e..0073903 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -23,7 +23,6 @@ class DBManager: self.conn = sqlite.connect(str(self.cfg.path)) cur = self.conn.cursor() cur.execute(f"PRAGMA key = '{self.cfg.key}';") - cur.execute("PRAGMA cipher_compatibility = 4;") cur.execute("PRAGMA journal_mode = WAL;") self.conn.commit() try: diff --git a/bouquin/editor.py b/bouquin/editor.py index 7fe55c0..eb3b664 100644 --- a/bouquin/editor.py +++ b/bouquin/editor.py @@ -2,22 +2,144 @@ from __future__ import annotations from PySide6.QtGui import ( QColor, + QDesktopServices, QFont, QFontDatabase, QTextCharFormat, + QTextCursor, QTextListFormat, QTextBlockFormat, ) -from PySide6.QtCore import Slot +from PySide6.QtCore import Qt, QUrl, Signal, Slot, QRegularExpression from PySide6.QtWidgets import QTextEdit class Editor(QTextEdit): - def __init__(self): - super().__init__() + linkActivated = Signal(str) + + _URL_RX = QRegularExpression(r"(https?://[^\s<>\"]+|www\.[^\s<>\"]+)") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) tab_w = 4 * self.fontMetrics().horizontalAdvance(" ") self.setTabStopDistance(tab_w) + self.setTextInteractionFlags( + Qt.TextInteractionFlag.TextEditorInteraction + | Qt.TextInteractionFlag.LinksAccessibleByMouse + | Qt.TextInteractionFlag.LinksAccessibleByKeyboard + ) + + self.setAcceptRichText(True) + + # Turn raw URLs into anchors + self._linkifying = False + self.textChanged.connect(self._linkify_document) + self.viewport().setMouseTracking(True) + + def _linkify_document(self): + if self._linkifying: + return + self._linkifying = True + + doc = self.document() + cur = QTextCursor(doc) + cur.beginEditBlock() + + block = doc.begin() + while block.isValid(): + text = block.text() + it = self._URL_RX.globalMatch(text) + while it.hasNext(): + m = it.next() + start = block.position() + m.capturedStart() + end = start + m.capturedLength() + + cur.setPosition(start) + cur.setPosition(end, QTextCursor.KeepAnchor) + + fmt = cur.charFormat() + if fmt.isAnchor(): # already linkified; skip + continue + + href = m.captured(0) + if href.startswith("www."): + href = "https://" + href + + fmt.setAnchor(True) + # Qt 6: use setAnchorHref; for compatibility, also set names. + try: + fmt.setAnchorHref(href) + except AttributeError: + fmt.setAnchorNames([href]) + + fmt.setFontUnderline(True) + fmt.setForeground(Qt.blue) + cur.setCharFormat(fmt) + + block = block.next() + + cur.endEditBlock() + self._linkifying = False + + def mouseReleaseEvent(self, e): + if e.button() == Qt.LeftButton and (e.modifiers() & Qt.ControlModifier): + href = self.anchorAt(e.pos()) + if href: + QDesktopServices.openUrl(QUrl.fromUserInput(href)) + self.linkActivated.emit(href) + return + super().mouseReleaseEvent(e) + + def mouseMoveEvent(self, e): + if (e.modifiers() & Qt.ControlModifier) and self.anchorAt(e.pos()): + self.viewport().setCursor(Qt.PointingHandCursor) + else: + self.viewport().setCursor(Qt.IBeamCursor) + super().mouseMoveEvent(e) + + def keyPressEvent(self, e): + key = e.key() + + # Pre-insert: stop link/format bleed for “word boundary” keys + if key in (Qt.Key_Space, Qt.Key_Tab): + self._break_anchor_for_next_char() + return super().keyPressEvent(e) + + # When pressing Enter/return key, insert first, then neutralise the empty block’s inline format + if key in (Qt.Key_Return, Qt.Key_Enter): + super().keyPressEvent(e) # create the new (possibly empty) paragraph + + # If we're on an empty block, clear the insertion char format so the + # *next* Enter will create another new line (not consume the press to reset formatting). + c = self.textCursor() + block = c.block() + if block.length() == 1: + self._clear_insertion_char_format() + return + + return super().keyPressEvent(e) + + def _clear_insertion_char_format(self): + """Reset inline typing format (keeps lists, alignment, margins, etc.).""" + nf = QTextCharFormat() + self.setCurrentCharFormat(nf) + + def _break_anchor_for_next_char(self): + c = self.textCursor() + fmt = c.charFormat() + if fmt.isAnchor() or fmt.fontUnderline() or fmt.foreground().style() != 0: + # clone, then strip just the link-specific bits so the next char is plain text + nf = QTextCharFormat(fmt) + nf.setAnchor(False) + nf.setFontUnderline(False) + nf.clearForeground() + try: + nf.setAnchorHref("") + except AttributeError: + nf.setAnchorNames([]) + self.setCurrentCharFormat(nf) + def merge_on_sel(self, fmt): """ Sets the styling on the selected characters. @@ -28,9 +150,15 @@ class Editor(QTextEdit): cursor.mergeCharFormat(fmt) self.mergeCurrentCharFormat(fmt) - @Slot(QFont.Weight) - def apply_weight(self, weight): + @Slot() + def apply_weight(self): + cur = self.currentCharFormat() fmt = QTextCharFormat() + weight = ( + QFont.Weight.Normal + if cur.fontWeight() == QFont.Weight.Bold + else QFont.Weight.Bold + ) fmt.setFontWeight(weight) self.merge_on_sel(fmt) diff --git a/bouquin/key_prompt.py b/bouquin/key_prompt.py index 1fe8dee..095093c 100644 --- a/bouquin/key_prompt.py +++ b/bouquin/key_prompt.py @@ -14,8 +14,8 @@ class KeyPrompt(QDialog): def __init__( self, parent=None, - title: str = "Unlock database", - message: str = "Enter SQLCipher key", + title: str = "Enter key", + message: str = "Enter key", ): super().__init__(parent) self.setWindowTitle(title) diff --git a/bouquin/main_window.py b/bouquin/main_window.py index bceaa8d..0f90197 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -1,11 +1,14 @@ from __future__ import annotations +import os import sys -from PySide6.QtCore import QDate, QTimer, Qt +from PySide6.QtCore import QDate, QTimer, Qt, QSettings from PySide6.QtGui import ( QAction, + QCursor, QFont, + QGuiApplication, QTextCharFormat, ) from PySide6.QtWidgets import ( @@ -23,7 +26,7 @@ from .db import DBManager from .editor import Editor from .key_prompt import KeyPrompt from .search import Search -from .settings import APP_NAME, load_db_config, save_db_config +from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config from .settings_dialog import SettingsDialog from .toolbar import ToolBar @@ -35,9 +38,18 @@ class MainWindow(QMainWindow): self.setMinimumSize(1000, 650) self.cfg = load_db_config() - # Always prompt for the key (we never store it) - if not self._prompt_for_key_until_valid(): - sys.exit(1) + if not os.path.exists(self.cfg.path): + # Fresh database/first time use, so guide the user re: setting a key + first_time = True + else: + first_time = False + + # Prompt for the key unless it is found in config + if not self.cfg.key: + if not self._prompt_for_key_until_valid(first_time): + sys.exit(1) + else: + self._try_connect() # ---- UI: Left fixed panel (calendar) + right editor ----------------- self.calendar = QCalendarWidget() @@ -139,6 +151,10 @@ class MainWindow(QMainWindow): self._load_selected_date() self._refresh_calendar_marks() + # Restore window position from settings + self.settings = QSettings(APP_ORG, APP_NAME) + self._restore_window_position() + def _try_connect(self) -> bool: """ Try to connect to the database. @@ -155,12 +171,18 @@ class MainWindow(QMainWindow): return False return ok - def _prompt_for_key_until_valid(self) -> bool: + def _prompt_for_key_until_valid(self, first_time: bool) -> bool: """ Prompt for the SQLCipher key. """ + if first_time: + title = "Set an encryption key" + message = "Bouquin encrypts your data.\n\nPlease create a strong passphrase to encrypt the notebook.\n\nYou can always change it later!" + else: + title = "Unlock encrypted notebook" + message = "Enter your key to unlock the notebook" while True: - dlg = KeyPrompt(self, message="Enter a key to unlock the notebook") + dlg = KeyPrompt(self, title, message) if dlg.exec() != QDialog.Accepted: return False self.cfg.key = dlg.key() @@ -206,6 +228,8 @@ class MainWindow(QMainWindow): self._dirty = False # track which date the editor currently represents self._active_date_iso = date_iso + qd = QDate.fromString(date_iso, "yyyy-MM-dd") + self.calendar.setSelectedDate(qd) def _on_text_changed(self): self._dirty = True @@ -281,8 +305,46 @@ class MainWindow(QMainWindow): self._load_selected_date() self._refresh_calendar_marks() + def _restore_window_position(self): + geom = self.settings.value("main/geometry", None) + state = self.settings.value("main/windowState", None) + was_max = self.settings.value("main/maximized", False, type=bool) + + if geom is not None: + self.restoreGeometry(geom) + if state is not None: + self.restoreState(state) + if not self._rect_on_any_screen(self.frameGeometry()): + self._move_to_cursor_screen_center() + else: + # First run: place window on the screen where the mouse cursor is. + self._move_to_cursor_screen_center() + + # If it was maximized, do that AFTER the window exists in the event loop. + if was_max: + QTimer.singleShot(0, self.showMaximized) + + def _rect_on_any_screen(self, rect): + for sc in QGuiApplication.screens(): + if sc.availableGeometry().intersects(rect): + return True + return False + + def _move_to_cursor_screen_center(self): + screen = ( + QGuiApplication.screenAt(QCursor.pos()) or QGuiApplication.primaryScreen() + ) + r = screen.availableGeometry() + # Center the window in that screen’s available area + self.move(r.center() - self.rect().center()) + def closeEvent(self, event): try: + # Save window position + self.settings.setValue("main/geometry", self.saveGeometry()) + self.settings.setValue("main/windowState", self.saveState()) + self.settings.setValue("main/maximized", self.isMaximized()) + # Ensure we save any last pending edits to the db self._save_current() self.db.close() except Exception: diff --git a/bouquin/search.py b/bouquin/search.py index 8177905..8cd2fd5 100644 --- a/bouquin/search.py +++ b/bouquin/search.py @@ -80,7 +80,7 @@ class Search(QWidget): 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 + content, query, radius=30, maxlen=90 ) # ---- Per-item widget: date on top, preview row below (with ellipses) ---- @@ -112,7 +112,7 @@ class Search(QWidget): preview = QLabel() preview.setTextFormat(Qt.TextFormat.RichText) preview.setWordWrap(True) - preview.setOpenExternalLinks(True) # keep links in your HTML clickable + preview.setOpenExternalLinks(True) preview.setText( frag_html if frag_html diff --git a/bouquin/settings.py b/bouquin/settings.py index 508e12f..ec45094 100644 --- a/bouquin/settings.py +++ b/bouquin/settings.py @@ -21,9 +21,11 @@ def get_settings() -> QSettings: def load_db_config() -> DBConfig: s = get_settings() path = Path(s.value("db/path", str(default_db_path()))) - return DBConfig(path=path, key="") + key = s.value("db/key", "") + return DBConfig(path=path, key=key) def save_db_config(cfg: DBConfig) -> None: s = get_settings() s.setValue("db/path", str(cfg.path)) + s.setValue("db/key", str(cfg.key)) diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py index a59e1c6..70ae8f6 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -3,8 +3,12 @@ from __future__ import annotations from pathlib import Path from PySide6.QtWidgets import ( + QCheckBox, QDialog, QFormLayout, + QFrame, + QGroupBox, + QLabel, QHBoxLayout, QVBoxLayout, QWidget, @@ -15,9 +19,12 @@ from PySide6.QtWidgets import ( QSizePolicy, QMessageBox, ) +from PySide6.QtCore import Qt, Slot +from PySide6.QtGui import QPalette + from .db import DBConfig, DBManager -from .settings import save_db_config +from .settings import load_db_config, save_db_config from .key_prompt import KeyPrompt @@ -27,10 +34,11 @@ class SettingsDialog(QDialog): self.setWindowTitle("Settings") self._cfg = DBConfig(path=cfg.path, key="") self._db = db + self.key = "" form = QFormLayout() form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow) - self.setMinimumWidth(520) + self.setMinimumWidth(560) self.setSizeGripEnabled(True) self.path_edit = QLineEdit(str(self._cfg.path)) @@ -47,18 +55,65 @@ class SettingsDialog(QDialog): h.setStretch(1, 0) form.addRow("Database path", path_row) + # Encryption settings + enc_group = QGroupBox("Encryption") + enc = QVBoxLayout(enc_group) + enc.setContentsMargins(12, 8, 12, 12) + enc.setSpacing(6) + + # Checkbox to remember key + self.save_key_btn = QCheckBox("Remember key") + current_settings = load_db_config() + if current_settings.key: + self.save_key_btn.setChecked(True) + else: + self.save_key_btn.setChecked(False) + self.save_key_btn.setCursor(Qt.PointingHandCursor) + self.save_key_btn.toggled.connect(self.save_key_btn_clicked) + enc.addWidget(self.save_key_btn, 0, Qt.AlignLeft) + + # Explanation for remembering key + self.save_key_label = QLabel( + "If you don't want to be prompted for your encryption key, check this to remember it. " + "WARNING: the key is saved to disk and could be recoverable if your disk is compromised." + ) + self.save_key_label.setWordWrap(True) + self.save_key_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) + # make it look secondary + pal = self.save_key_label.palette() + pal.setColor(self.save_key_label.foregroundRole(), pal.color(QPalette.Mid)) + self.save_key_label.setPalette(pal) + + exp_row = QHBoxLayout() + exp_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the checkbox + exp_row.addWidget(self.save_key_label) + enc.addLayout(exp_row) + + line = QFrame() + line.setFrameShape(QFrame.HLine) + line.setFrameShadow(QFrame.Sunken) + enc.addWidget(line) + # Change key button self.rekey_btn = QPushButton("Change key") + self.rekey_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) self.rekey_btn.clicked.connect(self._change_key) + enc.addWidget(self.rekey_btn, 0, Qt.AlignLeft) + # Put the group into the form so it spans the full width nicely + form.addRow(enc_group) + + # Buttons bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) bb.accepted.connect(self._save) bb.rejected.connect(self.reject) + # Root layout (adjust margins/spacing a bit) v = QVBoxLayout(self) + v.setContentsMargins(12, 12, 12, 12) + v.setSpacing(10) v.addLayout(form) - v.addWidget(self.rekey_btn) - v.addWidget(bb) + v.addWidget(bb, 0, Qt.AlignRight) def _browse(self): p, _ = QFileDialog.getSaveFileName( @@ -71,16 +126,16 @@ class SettingsDialog(QDialog): self.path_edit.setText(p) def _save(self): - self._cfg = DBConfig(path=Path(self.path_edit.text()), key="") + self._cfg = DBConfig(path=Path(self.path_edit.text()), key=self.key) save_db_config(self._cfg) self.accept() def _change_key(self): - p1 = KeyPrompt(self, title="Change key", message="Enter new key") + p1 = KeyPrompt(self, title="Change key", message="Enter a new encryption key") if p1.exec() != QDialog.Accepted: return new_key = p1.key() - p2 = KeyPrompt(self, title="Change key", message="Re-enter new key") + p2 = KeyPrompt(self, title="Change key", message="Re-enter the new key") if p2.exec() != QDialog.Accepted: return if new_key != p2.key(): @@ -92,11 +147,23 @@ class SettingsDialog(QDialog): try: self._db.rekey(new_key) QMessageBox.information( - self, "Key changed", "The database key was updated." + self, "Key changed", "The notebook was re-encrypted with the new key!" ) except Exception as e: QMessageBox.critical(self, "Error", f"Could not change key:\n{e}") + @Slot(bool) + def save_key_btn_clicked(self, checked: bool): + if checked: + p1 = KeyPrompt( + self, title="Enter your key", message="Enter the encryption key" + ) + if p1.exec() != QDialog.Accepted: + return + self.key = p1.key() + self._cfg = DBConfig(path=Path(self.path_edit.text()), key=self.key) + save_db_config(self._cfg) + @property def config(self) -> DBConfig: return self._cfg diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py index 93c7ee3..182b527 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -1,12 +1,12 @@ from __future__ import annotations from PySide6.QtCore import Signal, Qt -from PySide6.QtGui import QFont, QAction +from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase from PySide6.QtWidgets import QToolBar class ToolBar(QToolBar): - boldRequested = Signal(QFont.Weight) + boldRequested = Signal() italicRequested = Signal() underlineRequested = Signal() strikeRequested = Signal() @@ -18,81 +18,131 @@ class ToolBar(QToolBar): def __init__(self, parent=None): super().__init__("Format", parent) + self.setObjectName("Format") + self.setToolButtonStyle(Qt.ToolButtonTextOnly) self._build_actions() + self._apply_toolbar_styles() def _build_actions(self): - # Bold - bold = QAction("Bold", self) - bold.setShortcut("Ctrl+B") - bold.triggered.connect(lambda: self.boldRequested.emit(QFont.Weight.Bold)) + self.actBold = QAction("Bold", self) + self.actBold.setShortcut(QKeySequence.Bold) + self.actBold.triggered.connect(self.boldRequested) - italic = QAction("Italic", self) - italic.setShortcut("Ctrl+I") - italic.triggered.connect(self.italicRequested) + self.actItalic = QAction("Italic", self) + self.actItalic.setShortcut(QKeySequence.Italic) + self.actItalic.triggered.connect(self.italicRequested) - underline = QAction("Underline", self) - underline.setShortcut("Ctrl+U") - underline.triggered.connect(self.underlineRequested) + self.actUnderline = QAction("Underline", self) + self.actUnderline.setShortcut(QKeySequence.Underline) + self.actUnderline.triggered.connect(self.underlineRequested) - strike = QAction("Strikethrough", self) - strike.setShortcut("Ctrl+-") - strike.triggered.connect(self.strikeRequested) + self.actStrike = QAction("Strikethrough", self) + self.actStrike.setShortcut("Ctrl+-") + self.actStrike.triggered.connect(self.strikeRequested) - code = QAction("", self) - code.setShortcut("Ctrl+`") - code.triggered.connect(self.codeRequested) + self.actCode = QAction("Inline code", self) + self.actCode.setShortcut("Ctrl+`") + self.actCode.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)) + self.actH1 = QAction("Heading 1", self) + self.actH2 = QAction("Heading 2", self) + self.actH3 = QAction("Heading 3", self) + self.actNormal = QAction("Normal text", self) + self.actH1.setShortcut("Ctrl+1") + self.actH2.setShortcut("Ctrl+2") + self.actH3.setShortcut("Ctrl+3") + self.actNormal.setShortcut("Ctrl+N") + self.actH1.triggered.connect(lambda: self.headingRequested.emit(24)) + self.actH2.triggered.connect(lambda: self.headingRequested.emit(18)) + self.actH3.triggered.connect(lambda: self.headingRequested.emit(14)) + self.actNormal.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) + self.actBullets = QAction("Bulleted list", self) + self.actBullets.triggered.connect(self.bulletsRequested) + self.actNumbers = QAction("Numbered list", self) + self.actNumbers.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) + self.actAlignL = QAction("Align left", self) + self.actAlignC = QAction("Align center", self) + self.actAlignR = QAction("Align right", self) + self.actAlignL.triggered.connect(lambda: self.alignRequested.emit(Qt.AlignLeft)) + self.actAlignC.triggered.connect( + lambda: self.alignRequested.emit(Qt.AlignHCenter) ) - center.triggered.connect( - lambda: self.alignRequested.emit(Qt.AlignmentFlag.AlignHCenter) - ) - right.triggered.connect( - lambda: self.alignRequested.emit(Qt.AlignmentFlag.AlignRight) + self.actAlignR.triggered.connect( + lambda: self.alignRequested.emit(Qt.AlignRight) ) self.addActions( [ - bold, - italic, - underline, - strike, - code, - h1, - h2, - h3, - normal, - bullets, - numbers, - left, - center, - right, + self.actBold, + self.actItalic, + self.actUnderline, + self.actStrike, + self.actCode, + self.actH1, + self.actH2, + self.actH3, + self.actNormal, + self.actBullets, + self.actNumbers, + self.actAlignL, + self.actAlignC, + self.actAlignR, ] ) + + def _apply_toolbar_styles(self): + self._style_letter_button(self.actBold, "B", bold=True) + self._style_letter_button(self.actItalic, "I", italic=True) + self._style_letter_button(self.actUnderline, "U", underline=True) + self._style_letter_button(self.actStrike, "S", strike=True) + + # Monospace look for code; use a fixed font + code_font = QFontDatabase.systemFont(QFontDatabase.FixedFont) + self._style_letter_button(self.actCode, "", custom_font=code_font) + + # Headings + self._style_letter_button(self.actH1, "H1") + self._style_letter_button(self.actH2, "H2") + self._style_letter_button(self.actH3, "H3") + self._style_letter_button(self.actNormal, "N") + + # Lists + self._style_letter_button(self.actBullets, "•") + self._style_letter_button(self.actNumbers, "1.") + + # Alignment + self._style_letter_button(self.actAlignL, "L") + self._style_letter_button(self.actAlignC, "C") + self._style_letter_button(self.actAlignR, "R") + + def _style_letter_button( + self, + action: QAction, + text: str, + *, + bold: bool = False, + italic: bool = False, + underline: bool = False, + strike: bool = False, + custom_font: QFont | None = None, + ): + btn = self.widgetForAction(action) + if not btn: + return + btn.setText(text) + f = custom_font if custom_font is not None else QFont(btn.font()) + if custom_font is None: + f.setBold(bold) + f.setItalic(italic) + f.setUnderline(underline) + f.setStrikeOut(strike) + btn.setFont(f) + + # Keep accessibility/tooltip readable + btn.setToolTip(action.text()) + btn.setAccessibleName(action.text())