Compare commits
8 commits
ff3f5fcf3a
...
6cae652643
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cae652643 | |||
| 43bbe971eb | |||
| 4f773e1c1b | |||
| 327e7882b5 | |||
| c4f99f9b2b | |||
| baf9b41f44 | |||
| 39c0cb61da | |||
| f9d92811dc |
10 changed files with 406 additions and 87 deletions
|
|
@ -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
|
# 0.1.2
|
||||||
|
|
||||||
* Switch from Markdown to HTML via QTextEdit, with a toolbar
|
* Switch from Markdown to HTML via QTextEdit, with a toolbar
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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 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.
|
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
|
## How to install
|
||||||
|
|
||||||
|
Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions).
|
||||||
|
|
||||||
### From source
|
### From source
|
||||||
|
|
||||||
* Clone this repo or download the tarball from the releases page
|
* Clone this repo or download the tarball from the releases page
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ class DBManager:
|
||||||
self.conn = sqlite.connect(str(self.cfg.path))
|
self.conn = sqlite.connect(str(self.cfg.path))
|
||||||
cur = self.conn.cursor()
|
cur = self.conn.cursor()
|
||||||
cur.execute(f"PRAGMA key = '{self.cfg.key}';")
|
cur.execute(f"PRAGMA key = '{self.cfg.key}';")
|
||||||
cur.execute("PRAGMA cipher_compatibility = 4;")
|
|
||||||
cur.execute("PRAGMA journal_mode = WAL;")
|
cur.execute("PRAGMA journal_mode = WAL;")
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,144 @@ from __future__ import annotations
|
||||||
|
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
QColor,
|
QColor,
|
||||||
|
QDesktopServices,
|
||||||
QFont,
|
QFont,
|
||||||
QFontDatabase,
|
QFontDatabase,
|
||||||
QTextCharFormat,
|
QTextCharFormat,
|
||||||
|
QTextCursor,
|
||||||
QTextListFormat,
|
QTextListFormat,
|
||||||
QTextBlockFormat,
|
QTextBlockFormat,
|
||||||
)
|
)
|
||||||
from PySide6.QtCore import Slot
|
from PySide6.QtCore import Qt, QUrl, Signal, Slot, QRegularExpression
|
||||||
from PySide6.QtWidgets import QTextEdit
|
from PySide6.QtWidgets import QTextEdit
|
||||||
|
|
||||||
|
|
||||||
class Editor(QTextEdit):
|
class Editor(QTextEdit):
|
||||||
def __init__(self):
|
linkActivated = Signal(str)
|
||||||
super().__init__()
|
|
||||||
|
_URL_RX = QRegularExpression(r"(https?://[^\s<>\"]+|www\.[^\s<>\"]+)")
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
|
tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
|
||||||
self.setTabStopDistance(tab_w)
|
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):
|
def merge_on_sel(self, fmt):
|
||||||
"""
|
"""
|
||||||
Sets the styling on the selected characters.
|
Sets the styling on the selected characters.
|
||||||
|
|
@ -28,9 +150,15 @@ class Editor(QTextEdit):
|
||||||
cursor.mergeCharFormat(fmt)
|
cursor.mergeCharFormat(fmt)
|
||||||
self.mergeCurrentCharFormat(fmt)
|
self.mergeCurrentCharFormat(fmt)
|
||||||
|
|
||||||
@Slot(QFont.Weight)
|
@Slot()
|
||||||
def apply_weight(self, weight):
|
def apply_weight(self):
|
||||||
|
cur = self.currentCharFormat()
|
||||||
fmt = QTextCharFormat()
|
fmt = QTextCharFormat()
|
||||||
|
weight = (
|
||||||
|
QFont.Weight.Normal
|
||||||
|
if cur.fontWeight() == QFont.Weight.Bold
|
||||||
|
else QFont.Weight.Bold
|
||||||
|
)
|
||||||
fmt.setFontWeight(weight)
|
fmt.setFontWeight(weight)
|
||||||
self.merge_on_sel(fmt)
|
self.merge_on_sel(fmt)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ class KeyPrompt(QDialog):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
parent=None,
|
parent=None,
|
||||||
title: str = "Unlock database",
|
title: str = "Enter key",
|
||||||
message: str = "Enter SQLCipher key",
|
message: str = "Enter key",
|
||||||
):
|
):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle(title)
|
self.setWindowTitle(title)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from PySide6.QtCore import QDate, QTimer, Qt
|
from PySide6.QtCore import QDate, QTimer, Qt, QSettings
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
QAction,
|
QAction,
|
||||||
|
QCursor,
|
||||||
QFont,
|
QFont,
|
||||||
|
QGuiApplication,
|
||||||
QTextCharFormat,
|
QTextCharFormat,
|
||||||
)
|
)
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
|
@ -23,7 +26,7 @@ from .db import DBManager
|
||||||
from .editor import Editor
|
from .editor import Editor
|
||||||
from .key_prompt import KeyPrompt
|
from .key_prompt import KeyPrompt
|
||||||
from .search import Search
|
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 .settings_dialog import SettingsDialog
|
||||||
from .toolbar import ToolBar
|
from .toolbar import ToolBar
|
||||||
|
|
||||||
|
|
@ -35,9 +38,18 @@ class MainWindow(QMainWindow):
|
||||||
self.setMinimumSize(1000, 650)
|
self.setMinimumSize(1000, 650)
|
||||||
|
|
||||||
self.cfg = load_db_config()
|
self.cfg = load_db_config()
|
||||||
# Always prompt for the key (we never store it)
|
if not os.path.exists(self.cfg.path):
|
||||||
if not self._prompt_for_key_until_valid():
|
# Fresh database/first time use, so guide the user re: setting a key
|
||||||
sys.exit(1)
|
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 -----------------
|
# ---- UI: Left fixed panel (calendar) + right editor -----------------
|
||||||
self.calendar = QCalendarWidget()
|
self.calendar = QCalendarWidget()
|
||||||
|
|
@ -139,6 +151,10 @@ class MainWindow(QMainWindow):
|
||||||
self._load_selected_date()
|
self._load_selected_date()
|
||||||
self._refresh_calendar_marks()
|
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:
|
def _try_connect(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Try to connect to the database.
|
Try to connect to the database.
|
||||||
|
|
@ -155,12 +171,18 @@ class MainWindow(QMainWindow):
|
||||||
return False
|
return False
|
||||||
return ok
|
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.
|
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:
|
while True:
|
||||||
dlg = KeyPrompt(self, message="Enter a key to unlock the notebook")
|
dlg = KeyPrompt(self, title, message)
|
||||||
if dlg.exec() != QDialog.Accepted:
|
if dlg.exec() != QDialog.Accepted:
|
||||||
return False
|
return False
|
||||||
self.cfg.key = dlg.key()
|
self.cfg.key = dlg.key()
|
||||||
|
|
@ -206,6 +228,8 @@ class MainWindow(QMainWindow):
|
||||||
self._dirty = False
|
self._dirty = False
|
||||||
# track which date the editor currently represents
|
# track which date the editor currently represents
|
||||||
self._active_date_iso = date_iso
|
self._active_date_iso = date_iso
|
||||||
|
qd = QDate.fromString(date_iso, "yyyy-MM-dd")
|
||||||
|
self.calendar.setSelectedDate(qd)
|
||||||
|
|
||||||
def _on_text_changed(self):
|
def _on_text_changed(self):
|
||||||
self._dirty = True
|
self._dirty = True
|
||||||
|
|
@ -281,8 +305,46 @@ class MainWindow(QMainWindow):
|
||||||
self._load_selected_date()
|
self._load_selected_date()
|
||||||
self._refresh_calendar_marks()
|
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):
|
def closeEvent(self, event):
|
||||||
try:
|
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._save_current()
|
||||||
self.db.close()
|
self.db.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ class Search(QWidget):
|
||||||
for date_str, content in rows:
|
for date_str, content in rows:
|
||||||
# Build an HTML fragment around the match and whether to show ellipses
|
# Build an HTML fragment around the match and whether to show ellipses
|
||||||
frag_html, left_ell, right_ell = self._make_html_snippet(
|
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) ----
|
# ---- Per-item widget: date on top, preview row below (with ellipses) ----
|
||||||
|
|
@ -112,7 +112,7 @@ class Search(QWidget):
|
||||||
preview = QLabel()
|
preview = QLabel()
|
||||||
preview.setTextFormat(Qt.TextFormat.RichText)
|
preview.setTextFormat(Qt.TextFormat.RichText)
|
||||||
preview.setWordWrap(True)
|
preview.setWordWrap(True)
|
||||||
preview.setOpenExternalLinks(True) # keep links in your HTML clickable
|
preview.setOpenExternalLinks(True)
|
||||||
preview.setText(
|
preview.setText(
|
||||||
frag_html
|
frag_html
|
||||||
if frag_html
|
if frag_html
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,11 @@ def get_settings() -> QSettings:
|
||||||
def load_db_config() -> DBConfig:
|
def load_db_config() -> DBConfig:
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
path = Path(s.value("db/path", str(default_db_path())))
|
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:
|
def save_db_config(cfg: DBConfig) -> None:
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
s.setValue("db/path", str(cfg.path))
|
s.setValue("db/path", str(cfg.path))
|
||||||
|
s.setValue("db/key", str(cfg.key))
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,12 @@ from __future__ import annotations
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QCheckBox,
|
||||||
QDialog,
|
QDialog,
|
||||||
QFormLayout,
|
QFormLayout,
|
||||||
|
QFrame,
|
||||||
|
QGroupBox,
|
||||||
|
QLabel,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
|
|
@ -15,9 +19,12 @@ from PySide6.QtWidgets import (
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
)
|
)
|
||||||
|
from PySide6.QtCore import Qt, Slot
|
||||||
|
from PySide6.QtGui import QPalette
|
||||||
|
|
||||||
|
|
||||||
from .db import DBConfig, DBManager
|
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
|
from .key_prompt import KeyPrompt
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -27,10 +34,11 @@ class SettingsDialog(QDialog):
|
||||||
self.setWindowTitle("Settings")
|
self.setWindowTitle("Settings")
|
||||||
self._cfg = DBConfig(path=cfg.path, key="")
|
self._cfg = DBConfig(path=cfg.path, key="")
|
||||||
self._db = db
|
self._db = db
|
||||||
|
self.key = ""
|
||||||
|
|
||||||
form = QFormLayout()
|
form = QFormLayout()
|
||||||
form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
|
form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
|
||||||
self.setMinimumWidth(520)
|
self.setMinimumWidth(560)
|
||||||
self.setSizeGripEnabled(True)
|
self.setSizeGripEnabled(True)
|
||||||
|
|
||||||
self.path_edit = QLineEdit(str(self._cfg.path))
|
self.path_edit = QLineEdit(str(self._cfg.path))
|
||||||
|
|
@ -47,18 +55,65 @@ class SettingsDialog(QDialog):
|
||||||
h.setStretch(1, 0)
|
h.setStretch(1, 0)
|
||||||
form.addRow("Database path", path_row)
|
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
|
# Change key button
|
||||||
self.rekey_btn = QPushButton("Change key")
|
self.rekey_btn = QPushButton("Change key")
|
||||||
|
self.rekey_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||||
self.rekey_btn.clicked.connect(self._change_key)
|
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 = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
||||||
bb.accepted.connect(self._save)
|
bb.accepted.connect(self._save)
|
||||||
bb.rejected.connect(self.reject)
|
bb.rejected.connect(self.reject)
|
||||||
|
|
||||||
|
# Root layout (adjust margins/spacing a bit)
|
||||||
v = QVBoxLayout(self)
|
v = QVBoxLayout(self)
|
||||||
|
v.setContentsMargins(12, 12, 12, 12)
|
||||||
|
v.setSpacing(10)
|
||||||
v.addLayout(form)
|
v.addLayout(form)
|
||||||
v.addWidget(self.rekey_btn)
|
v.addWidget(bb, 0, Qt.AlignRight)
|
||||||
v.addWidget(bb)
|
|
||||||
|
|
||||||
def _browse(self):
|
def _browse(self):
|
||||||
p, _ = QFileDialog.getSaveFileName(
|
p, _ = QFileDialog.getSaveFileName(
|
||||||
|
|
@ -71,16 +126,16 @@ class SettingsDialog(QDialog):
|
||||||
self.path_edit.setText(p)
|
self.path_edit.setText(p)
|
||||||
|
|
||||||
def _save(self):
|
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)
|
save_db_config(self._cfg)
|
||||||
self.accept()
|
self.accept()
|
||||||
|
|
||||||
def _change_key(self):
|
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:
|
if p1.exec() != QDialog.Accepted:
|
||||||
return
|
return
|
||||||
new_key = p1.key()
|
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:
|
if p2.exec() != QDialog.Accepted:
|
||||||
return
|
return
|
||||||
if new_key != p2.key():
|
if new_key != p2.key():
|
||||||
|
|
@ -92,11 +147,23 @@ class SettingsDialog(QDialog):
|
||||||
try:
|
try:
|
||||||
self._db.rekey(new_key)
|
self._db.rekey(new_key)
|
||||||
QMessageBox.information(
|
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:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, "Error", f"Could not change key:\n{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
|
@property
|
||||||
def config(self) -> DBConfig:
|
def config(self) -> DBConfig:
|
||||||
return self._cfg
|
return self._cfg
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PySide6.QtCore import Signal, Qt
|
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
|
from PySide6.QtWidgets import QToolBar
|
||||||
|
|
||||||
|
|
||||||
class ToolBar(QToolBar):
|
class ToolBar(QToolBar):
|
||||||
boldRequested = Signal(QFont.Weight)
|
boldRequested = Signal()
|
||||||
italicRequested = Signal()
|
italicRequested = Signal()
|
||||||
underlineRequested = Signal()
|
underlineRequested = Signal()
|
||||||
strikeRequested = Signal()
|
strikeRequested = Signal()
|
||||||
|
|
@ -18,81 +18,131 @@ class ToolBar(QToolBar):
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__("Format", parent)
|
super().__init__("Format", parent)
|
||||||
|
self.setObjectName("Format")
|
||||||
|
self.setToolButtonStyle(Qt.ToolButtonTextOnly)
|
||||||
self._build_actions()
|
self._build_actions()
|
||||||
|
self._apply_toolbar_styles()
|
||||||
|
|
||||||
def _build_actions(self):
|
def _build_actions(self):
|
||||||
# Bold
|
self.actBold = QAction("Bold", self)
|
||||||
bold = QAction("Bold", self)
|
self.actBold.setShortcut(QKeySequence.Bold)
|
||||||
bold.setShortcut("Ctrl+B")
|
self.actBold.triggered.connect(self.boldRequested)
|
||||||
bold.triggered.connect(lambda: self.boldRequested.emit(QFont.Weight.Bold))
|
|
||||||
|
|
||||||
italic = QAction("Italic", self)
|
self.actItalic = QAction("Italic", self)
|
||||||
italic.setShortcut("Ctrl+I")
|
self.actItalic.setShortcut(QKeySequence.Italic)
|
||||||
italic.triggered.connect(self.italicRequested)
|
self.actItalic.triggered.connect(self.italicRequested)
|
||||||
|
|
||||||
underline = QAction("Underline", self)
|
self.actUnderline = QAction("Underline", self)
|
||||||
underline.setShortcut("Ctrl+U")
|
self.actUnderline.setShortcut(QKeySequence.Underline)
|
||||||
underline.triggered.connect(self.underlineRequested)
|
self.actUnderline.triggered.connect(self.underlineRequested)
|
||||||
|
|
||||||
strike = QAction("Strikethrough", self)
|
self.actStrike = QAction("Strikethrough", self)
|
||||||
strike.setShortcut("Ctrl+-")
|
self.actStrike.setShortcut("Ctrl+-")
|
||||||
strike.triggered.connect(self.strikeRequested)
|
self.actStrike.triggered.connect(self.strikeRequested)
|
||||||
|
|
||||||
code = QAction("<code>", self)
|
self.actCode = QAction("Inline code", self)
|
||||||
code.setShortcut("Ctrl+`")
|
self.actCode.setShortcut("Ctrl+`")
|
||||||
code.triggered.connect(self.codeRequested)
|
self.actCode.triggered.connect(self.codeRequested)
|
||||||
|
|
||||||
# Headings
|
# Headings
|
||||||
h1 = QAction("H1", self)
|
self.actH1 = QAction("Heading 1", self)
|
||||||
h1.setShortcut("Ctrl+1")
|
self.actH2 = QAction("Heading 2", self)
|
||||||
h2 = QAction("H2", self)
|
self.actH3 = QAction("Heading 3", self)
|
||||||
h2.setShortcut("Ctrl+2")
|
self.actNormal = QAction("Normal text", self)
|
||||||
h3 = QAction("H3", self)
|
self.actH1.setShortcut("Ctrl+1")
|
||||||
h3.setShortcut("Ctrl+3")
|
self.actH2.setShortcut("Ctrl+2")
|
||||||
normal = QAction("Normal", self)
|
self.actH3.setShortcut("Ctrl+3")
|
||||||
normal.setShortcut("Ctrl+P")
|
self.actNormal.setShortcut("Ctrl+N")
|
||||||
|
self.actH1.triggered.connect(lambda: self.headingRequested.emit(24))
|
||||||
h1.triggered.connect(lambda: self.headingRequested.emit(24))
|
self.actH2.triggered.connect(lambda: self.headingRequested.emit(18))
|
||||||
h2.triggered.connect(lambda: self.headingRequested.emit(18))
|
self.actH3.triggered.connect(lambda: self.headingRequested.emit(14))
|
||||||
h3.triggered.connect(lambda: self.headingRequested.emit(14))
|
self.actNormal.triggered.connect(lambda: self.headingRequested.emit(0))
|
||||||
normal.triggered.connect(lambda: self.headingRequested.emit(0))
|
|
||||||
|
|
||||||
# Lists
|
# Lists
|
||||||
bullets = QAction("• Bullets", self)
|
self.actBullets = QAction("Bulleted list", self)
|
||||||
bullets.triggered.connect(self.bulletsRequested)
|
self.actBullets.triggered.connect(self.bulletsRequested)
|
||||||
numbers = QAction("1. Numbered", self)
|
self.actNumbers = QAction("Numbered list", self)
|
||||||
numbers.triggered.connect(self.numbersRequested)
|
self.actNumbers.triggered.connect(self.numbersRequested)
|
||||||
|
|
||||||
# Alignment
|
# Alignment
|
||||||
left = QAction("Align Left", self)
|
self.actAlignL = QAction("Align left", self)
|
||||||
center = QAction("Align Center", self)
|
self.actAlignC = QAction("Align center", self)
|
||||||
right = QAction("Align Right", self)
|
self.actAlignR = QAction("Align right", self)
|
||||||
|
self.actAlignL.triggered.connect(lambda: self.alignRequested.emit(Qt.AlignLeft))
|
||||||
left.triggered.connect(
|
self.actAlignC.triggered.connect(
|
||||||
lambda: self.alignRequested.emit(Qt.AlignmentFlag.AlignLeft)
|
lambda: self.alignRequested.emit(Qt.AlignHCenter)
|
||||||
)
|
)
|
||||||
center.triggered.connect(
|
self.actAlignR.triggered.connect(
|
||||||
lambda: self.alignRequested.emit(Qt.AlignmentFlag.AlignHCenter)
|
lambda: self.alignRequested.emit(Qt.AlignRight)
|
||||||
)
|
|
||||||
right.triggered.connect(
|
|
||||||
lambda: self.alignRequested.emit(Qt.AlignmentFlag.AlignRight)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.addActions(
|
self.addActions(
|
||||||
[
|
[
|
||||||
bold,
|
self.actBold,
|
||||||
italic,
|
self.actItalic,
|
||||||
underline,
|
self.actUnderline,
|
||||||
strike,
|
self.actStrike,
|
||||||
code,
|
self.actCode,
|
||||||
h1,
|
self.actH1,
|
||||||
h2,
|
self.actH2,
|
||||||
h3,
|
self.actH3,
|
||||||
normal,
|
self.actNormal,
|
||||||
bullets,
|
self.actBullets,
|
||||||
numbers,
|
self.actNumbers,
|
||||||
left,
|
self.actAlignL,
|
||||||
center,
|
self.actAlignC,
|
||||||
right,
|
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())
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue