diff --git a/CHANGELOG.md b/CHANGELOG.md index 32fd2d5..bd2c84f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 * Fix bold toggle diff --git a/bouquin/db.py b/bouquin/db.py index 90aca09..39226f5 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -17,6 +17,7 @@ Entry = Tuple[str, str] class DBConfig: path: Path key: str + idle_minutes: int = 15 # 0 = never lock class DBManager: diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 7fe5498..d7726ca 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -4,7 +4,7 @@ import os import sys 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 ( QAction, QCursor, @@ -17,8 +17,10 @@ from PySide6.QtWidgets import ( QCalendarWidget, QDialog, QFileDialog, + QLabel, QMainWindow, QMessageBox, + QPushButton, QSizePolicy, QSplitter, QVBoxLayout, @@ -34,6 +36,61 @@ from .settings_dialog import SettingsDialog 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): def __init__(self): super().__init__() @@ -77,18 +134,18 @@ class MainWindow(QMainWindow): self.editor = Editor() # Toolbar for controlling styling - tb = ToolBar() - self.addToolBar(tb) + self.toolBar = ToolBar() + self.addToolBar(self.toolBar) # Wire toolbar intents to editor methods - tb.boldRequested.connect(self.editor.apply_weight) - tb.italicRequested.connect(self.editor.apply_italic) - tb.underlineRequested.connect(self.editor.apply_underline) - tb.strikeRequested.connect(self.editor.apply_strikethrough) - tb.codeRequested.connect(self.editor.apply_code) - tb.headingRequested.connect(self.editor.apply_heading) - tb.bulletsRequested.connect(self.editor.toggle_bullets) - tb.numbersRequested.connect(self.editor.toggle_numbers) - tb.alignRequested.connect(self.editor.setAlignment) + self.toolBar.boldRequested.connect(self.editor.apply_weight) + self.toolBar.italicRequested.connect(self.editor.apply_italic) + self.toolBar.underlineRequested.connect(self.editor.apply_underline) + self.toolBar.strikeRequested.connect(self.editor.apply_strikethrough) + self.toolBar.codeRequested.connect(self.editor.apply_code) + self.toolBar.headingRequested.connect(self.editor.apply_heading) + self.toolBar.bulletsRequested.connect(self.editor.toggle_bullets) + self.toolBar.numbersRequested.connect(self.editor.toggle_numbers) + self.toolBar.alignRequested.connect(self.editor.setAlignment) split = QSplitter() split.addWidget(left_panel) @@ -100,6 +157,24 @@ class MainWindow(QMainWindow): lay.addWidget(split) 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 self.statusBar().showMessage("Ready", 800) @@ -155,6 +230,12 @@ class MainWindow(QMainWindow): act_docs.triggered.connect(self._open_docs) help_menu.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 self._dirty = False @@ -305,21 +386,33 @@ class MainWindow(QMainWindow): def _open_settings(self): dlg = SettingsDialog(self.cfg, self.db, self) - if dlg.exec() == QDialog.Accepted: - new_cfg = dlg.config - if new_cfg.path != self.cfg.path: - # Save the new path to the notebook - self.cfg.path = new_cfg.path - save_db_config(self.cfg) - self.db.close() - # Prompt again for the key for the new path - if not self._prompt_for_key_until_valid(): - QMessageBox.warning( - self, "Reopen failed", "Could not unlock database at new path." - ) - return - self._load_selected_date() - self._refresh_calendar_marks() + if dlg.exec() != QDialog.Accepted: + return + + new_cfg = dlg.config + old_path = self.cfg.path + + # Update in-memory config from the dialog + 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) + + # 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() + if not self._prompt_for_key_until_valid(first_time=False): + QMessageBox.warning( + self, "Reopen failed", "Could not unlock database at new path." + ) + return + self._load_selected_date() + self._refresh_calendar_marks() def _restore_window_position(self): geom = self.settings.value("main/geometry", None) @@ -402,9 +495,77 @@ class MainWindow(QMainWindow): url_str = "https://git.mig5.net/mig5/bouquin/wiki/Help" url = QUrl.fromUserInput(url_str) if not QDesktopServices.openUrl(url): - QMessageBox.warning(self, "Open Documentation", - f"Couldn't open:\n{url.toDisplayString()}") + QMessageBox.warning( + 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 you’re 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): try: # Save window position diff --git a/bouquin/settings.py b/bouquin/settings.py index ec45094..fc92394 100644 --- a/bouquin/settings.py +++ b/bouquin/settings.py @@ -22,10 +22,12 @@ def load_db_config() -> DBConfig: s = get_settings() path = Path(s.value("db/path", str(default_db_path()))) 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: s = get_settings() s.setValue("db/path", str(cfg.path)) s.setValue("db/key", str(cfg.key)) + s.setValue("db/idle_minutes", str(cfg.idle_minutes)) diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py index 70ae8f6..2f8ce5d 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -17,6 +17,7 @@ from PySide6.QtWidgets import ( QFileDialog, QDialogButtonBox, QSizePolicy, + QSpinBox, QMessageBox, ) from PySide6.QtCore import Qt, Slot @@ -56,7 +57,7 @@ class SettingsDialog(QDialog): form.addRow("Database path", path_row) # Encryption settings - enc_group = QGroupBox("Encryption") + enc_group = QGroupBox("Encryption and Privacy") enc = QVBoxLayout(enc_group) enc.setContentsMargins(12, 8, 12, 12) enc.setSpacing(6) @@ -64,10 +65,8 @@ class SettingsDialog(QDialog): # 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.key = current_settings.key or "" + self.save_key_btn.setChecked(bool(self.key)) 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) @@ -100,6 +99,31 @@ class SettingsDialog(QDialog): self.rekey_btn.clicked.connect(self._change_key) 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 form.addRow(enc_group) @@ -126,7 +150,12 @@ class SettingsDialog(QDialog): self.path_edit.setText(p) 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) self.accept() @@ -155,14 +184,18 @@ class SettingsDialog(QDialog): @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) + if not self.key: + p1 = KeyPrompt( + self, title="Enter your key", message="Enter the encryption key" + ) + if p1.exec() != QDialog.Accepted: + self.save_key_btn.blockSignals(True) + self.save_key_btn.setChecked(False) + self.save_key_btn.blockSignals(False) + return + self.key = p1.key() or "" + else: + self.key = "" @property def config(self) -> DBConfig: diff --git a/pyproject.toml b/pyproject.toml index 84f891f..37d4413 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bouquin" -version = "0.1.3" +version = "0.1.4" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel Jacq "] readme = "README.md"