From f9d92811dca8e974e4d73bdacd047cb50ccb31a2 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Sun, 2 Nov 2025 10:04:34 +1100 Subject: [PATCH 1/8] Remove explicit cipher_compatability PRAGMA --- bouquin/db.py | 1 - 1 file changed, 1 deletion(-) 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: From 39c0cb61da8da6778d3b3cbd6fbab87cd288f194 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Sun, 2 Nov 2025 10:15:27 +1100 Subject: [PATCH 2/8] Fix set/unset of bold text --- bouquin/editor.py | 6 ++++-- bouquin/toolbar.py | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/bouquin/editor.py b/bouquin/editor.py index 7fe55c0..a5b7643 100644 --- a/bouquin/editor.py +++ b/bouquin/editor.py @@ -28,9 +28,11 @@ 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/toolbar.py b/bouquin/toolbar.py index 93c7ee3..a3a8cef 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -6,7 +6,7 @@ from PySide6.QtWidgets import QToolBar class ToolBar(QToolBar): - boldRequested = Signal(QFont.Weight) + boldRequested = Signal() italicRequested = Signal() underlineRequested = Signal() strikeRequested = Signal() @@ -24,7 +24,7 @@ class ToolBar(QToolBar): # Bold bold = QAction("Bold", self) bold.setShortcut("Ctrl+B") - bold.triggered.connect(lambda: self.boldRequested.emit(QFont.Weight.Bold)) + bold.triggered.connect(self.boldRequested) italic = QAction("Italic", self) italic.setShortcut("Ctrl+I") From baf9b41f44aab3a93ffa14355ca59191331f89cc Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Sun, 2 Nov 2025 10:21:42 +1100 Subject: [PATCH 3/8] Reduce size of preview. Fix jumping to the appropriate day in calendar when clicking on a search result --- bouquin/main_window.py | 2 ++ bouquin/search.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bouquin/main_window.py b/bouquin/main_window.py index bceaa8d..5208e7b 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -206,6 +206,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 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 From c4f99f9b2b018310cc772c94a8983637543d20ba Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Sun, 2 Nov 2025 10:59:43 +1100 Subject: [PATCH 4/8] Remember app window position on screen --- bouquin/main_window.py | 45 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 5208e7b..0e1364f 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -2,10 +2,12 @@ from __future__ import annotations 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 ( @@ -139,6 +141,10 @@ class MainWindow(QMainWindow): self._load_selected_date() self._refresh_calendar_marks() + # Restore window position from settings + self.settings = QSettings(APP_NAME) + self._restore_window_position() + def _try_connect(self) -> bool: """ Try to connect to the database. @@ -283,8 +289,45 @@ 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: From 327e7882b5021094146988eed1da83d80385ecd0 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Sun, 2 Nov 2025 11:00:00 +1100 Subject: [PATCH 5/8] Clickable URL links --- bouquin/editor.py | 130 ++++++++++++++++++++++++++++++++++++- bouquin/toolbar.py | 1 + bouquin/url_highlighter.py | 25 +++++++ 3 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 bouquin/url_highlighter.py diff --git a/bouquin/editor.py b/bouquin/editor.py index a5b7643..1f7e132 100644 --- a/bouquin/editor.py +++ b/bouquin/editor.py @@ -1,23 +1,147 @@ from __future__ import annotations from PySide6.QtGui import ( + QBrush, 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 +from .url_highlighter import UrlHighlighter 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. diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py index a3a8cef..0951f20 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -19,6 +19,7 @@ class ToolBar(QToolBar): def __init__(self, parent=None): super().__init__("Format", parent) self._build_actions() + self.setObjectName("Format") def _build_actions(self): # Bold diff --git a/bouquin/url_highlighter.py b/bouquin/url_highlighter.py new file mode 100644 index 0000000..1fd1af6 --- /dev/null +++ b/bouquin/url_highlighter.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import re +from PySide6.QtGui import QSyntaxHighlighter, QTextCharFormat +from PySide6.QtCore import Qt, QRegularExpression + +class UrlHighlighter(QSyntaxHighlighter): + def __init__(self, doc): + super().__init__(doc) + self.rx = QRegularExpression(r"(https?://[^\s<>\"]+|www\.[^\s<>\"]+)") + + def highlightBlock(self, text: str): + it = self.rx.globalMatch(text) + while it.hasNext(): + m = it.next() + href = m.captured(0) + if href.startswith("www."): + href = "https://" + href + + fmt = QTextCharFormat() + fmt.setAnchor(True) + fmt.setAnchorHref(href) + fmt.setFontUnderline(True) + fmt.setForeground(Qt.blue) + self.setFormat(m.capturedStart(0), m.capturedLength(0), fmt) From 4f773e1c1b8085a1ec88c80de2183e7240470232 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Sun, 2 Nov 2025 11:13:52 +1100 Subject: [PATCH 6/8] Detect fresh install and guide the user to set an encryption passphrase so they know why they're prompted --- bouquin/key_prompt.py | 4 ++-- bouquin/main_window.py | 19 ++++++++++++++++--- bouquin/settings_dialog.py | 6 +++--- 3 files changed, 21 insertions(+), 8 deletions(-) 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 0e1364f..8a03852 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import sys from PySide6.QtCore import QDate, QTimer, Qt, QSettings @@ -37,8 +38,14 @@ class MainWindow(QMainWindow): self.setMinimumSize(1000, 650) self.cfg = load_db_config() + 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 + # Always prompt for the key (we never store it) - if not self._prompt_for_key_until_valid(): + if not self._prompt_for_key_until_valid(first_time): sys.exit(1) # ---- UI: Left fixed panel (calendar) + right editor ----------------- @@ -161,12 +168,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() diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py index a59e1c6..d739630 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -76,11 +76,11 @@ class SettingsDialog(QDialog): 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,7 +92,7 @@ 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}") From 43bbe971eb855ece25b9e6b3e28307cba8a3b0e3 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Sun, 2 Nov 2025 11:44:22 +1100 Subject: [PATCH 7/8] Add ability to save the key to avoid being prompted for it --- README.md | 4 +- bouquin/editor.py | 14 ++++--- bouquin/main_window.py | 18 +++++---- bouquin/settings.py | 4 +- bouquin/settings_dialog.py | 77 +++++++++++++++++++++++++++++++++++--- bouquin/toolbar.py | 2 +- bouquin/url_highlighter.py | 25 ------------- 7 files changed, 98 insertions(+), 46 deletions(-) delete mode 100644 bouquin/url_highlighter.py 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/editor.py b/bouquin/editor.py index 1f7e132..eb3b664 100644 --- a/bouquin/editor.py +++ b/bouquin/editor.py @@ -1,7 +1,6 @@ from __future__ import annotations from PySide6.QtGui import ( - QBrush, QColor, QDesktopServices, QFont, @@ -14,7 +13,6 @@ from PySide6.QtGui import ( from PySide6.QtCore import Qt, QUrl, Signal, Slot, QRegularExpression from PySide6.QtWidgets import QTextEdit -from .url_highlighter import UrlHighlighter class Editor(QTextEdit): linkActivated = Signal(str) @@ -55,13 +53,13 @@ class Editor(QTextEdit): while it.hasNext(): m = it.next() start = block.position() + m.capturedStart() - end = start + m.capturedLength() + end = start + m.capturedLength() cur.setPosition(start) cur.setPosition(end, QTextCursor.KeepAnchor) fmt = cur.charFormat() - if fmt.isAnchor(): # already linkified; skip + if fmt.isAnchor(): # already linkified; skip continue href = m.captured(0) @@ -110,7 +108,7 @@ class Editor(QTextEdit): # 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 + 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). @@ -156,7 +154,11 @@ class Editor(QTextEdit): def apply_weight(self): cur = self.currentCharFormat() fmt = QTextCharFormat() - weight = QFont.Weight.Normal if cur.fontWeight() == QFont.Weight.Bold else QFont.Weight.Bold + 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/main_window.py b/bouquin/main_window.py index 8a03852..0f90197 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -26,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 @@ -44,9 +44,12 @@ class MainWindow(QMainWindow): else: first_time = False - # Always prompt for the key (we never store it) - if not self._prompt_for_key_until_valid(first_time): - sys.exit(1) + # 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() @@ -149,7 +152,7 @@ class MainWindow(QMainWindow): self._refresh_calendar_marks() # Restore window position from settings - self.settings = QSettings(APP_NAME) + self.settings = QSettings(APP_ORG, APP_NAME) self._restore_window_position() def _try_connect(self) -> bool: @@ -328,12 +331,13 @@ class MainWindow(QMainWindow): return False def _move_to_cursor_screen_center(self): - screen = QGuiApplication.screenAt(QCursor.pos()) or QGuiApplication.primaryScreen() + 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 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 d739630..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,7 +126,7 @@ 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() @@ -97,6 +152,18 @@ class SettingsDialog(QDialog): 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 0951f20..c796ac8 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -1,7 +1,7 @@ from __future__ import annotations from PySide6.QtCore import Signal, Qt -from PySide6.QtGui import QFont, QAction +from PySide6.QtGui import QAction from PySide6.QtWidgets import QToolBar diff --git a/bouquin/url_highlighter.py b/bouquin/url_highlighter.py deleted file mode 100644 index 1fd1af6..0000000 --- a/bouquin/url_highlighter.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -import re -from PySide6.QtGui import QSyntaxHighlighter, QTextCharFormat -from PySide6.QtCore import Qt, QRegularExpression - -class UrlHighlighter(QSyntaxHighlighter): - def __init__(self, doc): - super().__init__(doc) - self.rx = QRegularExpression(r"(https?://[^\s<>\"]+|www\.[^\s<>\"]+)") - - def highlightBlock(self, text: str): - it = self.rx.globalMatch(text) - while it.hasNext(): - m = it.next() - href = m.captured(0) - if href.startswith("www."): - href = "https://" + href - - fmt = QTextCharFormat() - fmt.setAnchor(True) - fmt.setAnchorHref(href) - fmt.setFontUnderline(True) - fmt.setForeground(Qt.blue) - self.setFormat(m.capturedStart(0), m.capturedLength(0), fmt) From 6cae652643a18627a747753ae6534e19225f077c Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Sun, 2 Nov 2025 11:56:28 +1100 Subject: [PATCH 8/8] Abbreviated toolbar symbols and add tooltips --- CHANGELOG.md | 9 +++ bouquin/toolbar.py | 169 +++++++++++++++++++++++++++++---------------- 2 files changed, 118 insertions(+), 60 deletions(-) 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/bouquin/toolbar.py b/bouquin/toolbar.py index c796ac8..182b527 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -1,7 +1,7 @@ from __future__ import annotations from PySide6.QtCore import Signal, Qt -from PySide6.QtGui import QAction +from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase from PySide6.QtWidgets import QToolBar @@ -18,82 +18,131 @@ class ToolBar(QToolBar): def __init__(self, parent=None): super().__init__("Format", parent) - self._build_actions() 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(self.boldRequested) + 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())