Add auto-lock feature and 'report a bug'

This commit is contained in:
Miguel Jacq 2025-11-02 15:42:42 +11:00
parent c4091d4cee
commit ef50c8911e
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
6 changed files with 247 additions and 45 deletions

View file

@ -1,3 +1,8 @@
# 0.1.4
* Add auto-lock of app (configurable in Settings, defaults to 15 minutes)
* Add 'Report a bug' to Help nav
# 0.1.3 # 0.1.3
* Fix bold toggle * Fix bold toggle

View file

@ -17,6 +17,7 @@ Entry = Tuple[str, str]
class DBConfig: class DBConfig:
path: Path path: Path
key: str key: str
idle_minutes: int = 15 # 0 = never lock
class DBManager: class DBManager:

View file

@ -4,7 +4,7 @@ import os
import sys import sys
from pathlib import Path from pathlib import Path
from PySide6.QtCore import QDate, QTimer, Qt, QSettings, Slot, QUrl from PySide6.QtCore import QDate, QTimer, Qt, QSettings, Slot, QUrl, QEvent
from PySide6.QtGui import ( from PySide6.QtGui import (
QAction, QAction,
QCursor, QCursor,
@ -17,8 +17,10 @@ from PySide6.QtWidgets import (
QCalendarWidget, QCalendarWidget,
QDialog, QDialog,
QFileDialog, QFileDialog,
QLabel,
QMainWindow, QMainWindow,
QMessageBox, QMessageBox,
QPushButton,
QSizePolicy, QSizePolicy,
QSplitter, QSplitter,
QVBoxLayout, QVBoxLayout,
@ -34,6 +36,61 @@ from .settings_dialog import SettingsDialog
from .toolbar import ToolBar from .toolbar import ToolBar
class _LockOverlay(QWidget):
def __init__(self, parent: QWidget, on_unlock: callable):
super().__init__(parent)
self.setObjectName("LockOverlay")
self.setAttribute(Qt.WA_StyledBackground, True)
self.setFocusPolicy(Qt.StrongFocus)
self.setGeometry(parent.rect())
self.setStyleSheet(
"""
#LockOverlay { background-color: #ccc; }
#LockOverlay QLabel { color: #fff; font-size: 18px; }
#LockOverlay QPushButton {
background-color: #f2f2f2;
color: #000;
padding: 6px 14px;
border: 1px solid #808080;
border-radius: 6px;
font-size: 14px;
}
#LockOverlay QPushButton:hover { background-color: #ffffff; }
#LockOverlay QPushButton:pressed { background-color: #e6e6e6; }
"""
)
lay = QVBoxLayout(self)
lay.addStretch(1)
msg = QLabel("Locked due to inactivity")
msg.setAlignment(Qt.AlignCenter)
self._btn = QPushButton("Unlock")
self._btn.setFixedWidth(200)
self._btn.setCursor(Qt.PointingHandCursor)
self._btn.setAutoDefault(True)
self._btn.setDefault(True)
self._btn.clicked.connect(on_unlock)
lay.addWidget(msg, 0, Qt.AlignCenter)
lay.addWidget(self._btn, 0, Qt.AlignCenter)
lay.addStretch(1)
self.hide() # start hidden
# keep overlay sized with its parent
def eventFilter(self, obj, event):
if obj is self.parent() and event.type() in (QEvent.Resize, QEvent.Show):
self.setGeometry(obj.rect())
return False
def showEvent(self, e):
super().showEvent(e)
self._btn.setFocus()
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@ -77,18 +134,18 @@ class MainWindow(QMainWindow):
self.editor = Editor() self.editor = Editor()
# Toolbar for controlling styling # Toolbar for controlling styling
tb = ToolBar() self.toolBar = ToolBar()
self.addToolBar(tb) self.addToolBar(self.toolBar)
# Wire toolbar intents to editor methods # Wire toolbar intents to editor methods
tb.boldRequested.connect(self.editor.apply_weight) self.toolBar.boldRequested.connect(self.editor.apply_weight)
tb.italicRequested.connect(self.editor.apply_italic) self.toolBar.italicRequested.connect(self.editor.apply_italic)
tb.underlineRequested.connect(self.editor.apply_underline) self.toolBar.underlineRequested.connect(self.editor.apply_underline)
tb.strikeRequested.connect(self.editor.apply_strikethrough) self.toolBar.strikeRequested.connect(self.editor.apply_strikethrough)
tb.codeRequested.connect(self.editor.apply_code) self.toolBar.codeRequested.connect(self.editor.apply_code)
tb.headingRequested.connect(self.editor.apply_heading) self.toolBar.headingRequested.connect(self.editor.apply_heading)
tb.bulletsRequested.connect(self.editor.toggle_bullets) self.toolBar.bulletsRequested.connect(self.editor.toggle_bullets)
tb.numbersRequested.connect(self.editor.toggle_numbers) self.toolBar.numbersRequested.connect(self.editor.toggle_numbers)
tb.alignRequested.connect(self.editor.setAlignment) self.toolBar.alignRequested.connect(self.editor.setAlignment)
split = QSplitter() split = QSplitter()
split.addWidget(left_panel) split.addWidget(left_panel)
@ -100,6 +157,24 @@ class MainWindow(QMainWindow):
lay.addWidget(split) lay.addWidget(split)
self.setCentralWidget(container) self.setCentralWidget(container)
# Idle lock setup
self._idle_timer = QTimer(self)
self._idle_timer.setSingleShot(True)
self._idle_timer.timeout.connect(self._enter_lock)
self._apply_idle_minutes(getattr(self.cfg, "idle_minutes", 15))
self._idle_timer.start()
# full-window overlay that sits on top of the central widget
self._lock_overlay = _LockOverlay(self.centralWidget(), self._on_unlock_clicked)
self.centralWidget().installEventFilter(self._lock_overlay)
self._locked = False
# reset idle timer on any key press anywhere in the app
from PySide6.QtWidgets import QApplication
QApplication.instance().installEventFilter(self)
# Status bar for feedback # Status bar for feedback
self.statusBar().showMessage("Ready", 800) self.statusBar().showMessage("Ready", 800)
@ -155,6 +230,12 @@ class MainWindow(QMainWindow):
act_docs.triggered.connect(self._open_docs) act_docs.triggered.connect(self._open_docs)
help_menu.addAction(act_docs) help_menu.addAction(act_docs)
self.addAction(act_docs) self.addAction(act_docs)
act_bugs = QAction("Report a bug", self)
act_bugs.setShortcut("Ctrl+R")
act_bugs.setShortcutContext(Qt.ApplicationShortcut)
act_bugs.triggered.connect(self._open_bugs)
help_menu.addAction(act_bugs)
self.addAction(act_bugs)
# Autosave # Autosave
self._dirty = False self._dirty = False
@ -305,15 +386,27 @@ class MainWindow(QMainWindow):
def _open_settings(self): def _open_settings(self):
dlg = SettingsDialog(self.cfg, self.db, self) dlg = SettingsDialog(self.cfg, self.db, self)
if dlg.exec() == QDialog.Accepted: if dlg.exec() != QDialog.Accepted:
return
new_cfg = dlg.config new_cfg = dlg.config
if new_cfg.path != self.cfg.path: old_path = self.cfg.path
# Save the new path to the notebook
# Update in-memory config from the dialog
self.cfg.path = new_cfg.path self.cfg.path = new_cfg.path
self.cfg.key = new_cfg.key
self.cfg.idle_minutes = getattr(new_cfg, "idle_minutes", self.cfg.idle_minutes)
# Persist once
save_db_config(self.cfg) save_db_config(self.cfg)
# Apply idle setting immediately (restart the timer with new interval if it changed)
self._apply_idle_minutes(self.cfg.idle_minutes)
# If the DB path changed, reconnect
if self.cfg.path != old_path:
self.db.close() self.db.close()
# Prompt again for the key for the new path if not self._prompt_for_key_until_valid(first_time=False):
if not self._prompt_for_key_until_valid():
QMessageBox.warning( QMessageBox.warning(
self, "Reopen failed", "Could not unlock database at new path." self, "Reopen failed", "Could not unlock database at new path."
) )
@ -402,9 +495,77 @@ class MainWindow(QMainWindow):
url_str = "https://git.mig5.net/mig5/bouquin/wiki/Help" url_str = "https://git.mig5.net/mig5/bouquin/wiki/Help"
url = QUrl.fromUserInput(url_str) url = QUrl.fromUserInput(url_str)
if not QDesktopServices.openUrl(url): if not QDesktopServices.openUrl(url):
QMessageBox.warning(self, "Open Documentation", QMessageBox.warning(
f"Couldn't open:\n{url.toDisplayString()}") self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}"
)
def _open_bugs(self):
url_str = "https://nr.mig5.net/forms/mig5/contact"
url = QUrl.fromUserInput(url_str)
if not QDesktopServices.openUrl(url):
QMessageBox.warning(
self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}"
)
# Idle handlers
def _apply_idle_minutes(self, minutes: int):
minutes = max(0, int(minutes))
if not hasattr(self, "_idle_timer"):
return
if minutes == 0:
self._idle_timer.stop()
# If youre currently locked, unlock when user disables the timer:
if getattr(self, "_locked", False):
try:
self._locked = False
if hasattr(self, "_lock_overlay"):
self._lock_overlay.hide()
except Exception:
pass
else:
self._idle_timer.setInterval(minutes * 60 * 1000)
if not getattr(self, "_locked", False):
self._idle_timer.start()
def eventFilter(self, obj, event):
if event.type() == QEvent.KeyPress and not self._locked:
self._idle_timer.start()
return super().eventFilter(obj, event)
def _enter_lock(self):
if self._locked:
return
self._locked = True
if self.menuBar():
self.menuBar().setEnabled(False)
if self.statusBar():
self.statusBar().setEnabled(False)
tb = getattr(self, "toolBar", None)
if tb:
tb.setEnabled(False)
self._lock_overlay.show()
self._lock_overlay.raise_()
@Slot()
def _on_unlock_clicked(self):
try:
ok = self._prompt_for_key_until_valid(first_time=False)
except Exception as e:
QMessageBox.critical(self, "Unlock failed", str(e))
return
if ok:
self._locked = False
self._lock_overlay.hide()
if self.menuBar():
self.menuBar().setEnabled(True)
if self.statusBar():
self.statusBar().setEnabled(True)
tb = getattr(self, "toolBar", None)
if tb:
tb.setEnabled(True)
self._idle_timer.start()
# Close app handler - save window position and database
def closeEvent(self, event): def closeEvent(self, event):
try: try:
# Save window position # Save window position

View file

@ -22,10 +22,12 @@ 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())))
key = s.value("db/key", "") key = s.value("db/key", "")
return DBConfig(path=path, key=key) idle = s.value("db/idle_minutes", 15, type=int)
return DBConfig(path=path, key=key, idle_minutes=idle)
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)) s.setValue("db/key", str(cfg.key))
s.setValue("db/idle_minutes", str(cfg.idle_minutes))

View file

@ -17,6 +17,7 @@ from PySide6.QtWidgets import (
QFileDialog, QFileDialog,
QDialogButtonBox, QDialogButtonBox,
QSizePolicy, QSizePolicy,
QSpinBox,
QMessageBox, QMessageBox,
) )
from PySide6.QtCore import Qt, Slot from PySide6.QtCore import Qt, Slot
@ -56,7 +57,7 @@ class SettingsDialog(QDialog):
form.addRow("Database path", path_row) form.addRow("Database path", path_row)
# Encryption settings # Encryption settings
enc_group = QGroupBox("Encryption") enc_group = QGroupBox("Encryption and Privacy")
enc = QVBoxLayout(enc_group) enc = QVBoxLayout(enc_group)
enc.setContentsMargins(12, 8, 12, 12) enc.setContentsMargins(12, 8, 12, 12)
enc.setSpacing(6) enc.setSpacing(6)
@ -64,10 +65,8 @@ class SettingsDialog(QDialog):
# Checkbox to remember key # Checkbox to remember key
self.save_key_btn = QCheckBox("Remember key") self.save_key_btn = QCheckBox("Remember key")
current_settings = load_db_config() current_settings = load_db_config()
if current_settings.key: self.key = current_settings.key or ""
self.save_key_btn.setChecked(True) self.save_key_btn.setChecked(bool(self.key))
else:
self.save_key_btn.setChecked(False)
self.save_key_btn.setCursor(Qt.PointingHandCursor) self.save_key_btn.setCursor(Qt.PointingHandCursor)
self.save_key_btn.toggled.connect(self.save_key_btn_clicked) self.save_key_btn.toggled.connect(self.save_key_btn_clicked)
enc.addWidget(self.save_key_btn, 0, Qt.AlignLeft) enc.addWidget(self.save_key_btn, 0, Qt.AlignLeft)
@ -100,6 +99,31 @@ class SettingsDialog(QDialog):
self.rekey_btn.clicked.connect(self._change_key) self.rekey_btn.clicked.connect(self._change_key)
enc.addWidget(self.rekey_btn, 0, Qt.AlignLeft) enc.addWidget(self.rekey_btn, 0, Qt.AlignLeft)
self.idle_spin = QSpinBox()
self.idle_spin.setRange(0, 240)
self.idle_spin.setSingleStep(1)
self.idle_spin.setAccelerated(True)
self.idle_spin.setSuffix(" min")
self.idle_spin.setSpecialValueText("Never")
self.idle_spin.setValue(getattr(cfg, "idle_minutes", 15))
enc.addWidget(self.idle_spin, 0, Qt.AlignLeft)
# Explanation for idle option (autolock)
self.idle_spin_label = QLabel(
"Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it. "
"Set to 0 (never) to never lock."
)
self.idle_spin_label.setWordWrap(True)
self.idle_spin_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
# make it look secondary
spal = self.idle_spin_label.palette()
spal.setColor(self.idle_spin_label.foregroundRole(), spal.color(QPalette.Mid))
self.idle_spin_label.setPalette(spal)
spin_row = QHBoxLayout()
spin_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the spinbox
spin_row.addWidget(self.idle_spin_label)
enc.addLayout(spin_row)
# Put the group into the form so it spans the full width nicely # Put the group into the form so it spans the full width nicely
form.addRow(enc_group) form.addRow(enc_group)
@ -126,7 +150,12 @@ 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.key) key_to_save = self.key if self.save_key_btn.isChecked() else ""
self._cfg = DBConfig(
path=Path(self.path_edit.text()),
key=key_to_save,
idle_minutes=self.idle_spin.value(),
)
save_db_config(self._cfg) save_db_config(self._cfg)
self.accept() self.accept()
@ -155,14 +184,18 @@ class SettingsDialog(QDialog):
@Slot(bool) @Slot(bool)
def save_key_btn_clicked(self, checked: bool): def save_key_btn_clicked(self, checked: bool):
if checked: if checked:
if not self.key:
p1 = KeyPrompt( p1 = KeyPrompt(
self, title="Enter your key", message="Enter the encryption key" self, title="Enter your key", message="Enter the encryption key"
) )
if p1.exec() != QDialog.Accepted: if p1.exec() != QDialog.Accepted:
self.save_key_btn.blockSignals(True)
self.save_key_btn.setChecked(False)
self.save_key_btn.blockSignals(False)
return return
self.key = p1.key() self.key = p1.key() or ""
self._cfg = DBConfig(path=Path(self.path_edit.text()), key=self.key) else:
save_db_config(self._cfg) self.key = ""
@property @property
def config(self) -> DBConfig: def config(self) -> DBConfig:

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "bouquin" name = "bouquin"
version = "0.1.3" version = "0.1.4"
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
authors = ["Miguel Jacq <mig@mig5.net>"] authors = ["Miguel Jacq <mig@mig5.net>"]
readme = "README.md" readme = "README.md"