bouquin/bouquin/main_window.py

348 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import os
import sys
from PySide6.QtCore import QDate, QTimer, Qt, QSettings
from PySide6.QtGui import (
QAction,
QCursor,
QFont,
QGuiApplication,
QTextCharFormat,
)
from PySide6.QtWidgets import (
QCalendarWidget,
QDialog,
QMainWindow,
QMessageBox,
QSizePolicy,
QSplitter,
QVBoxLayout,
QWidget,
)
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_dialog import SettingsDialog
from .toolbar import ToolBar
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle(APP_NAME)
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(first_time):
sys.exit(1)
# ---- UI: Left fixed panel (calendar) + right editor -----------------
self.calendar = QCalendarWidget()
self.calendar.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.calendar.setGridVisible(True)
self.calendar.selectionChanged.connect(self._on_date_changed)
self.search = Search(self.db)
self.search.openDateRequested.connect(self._load_selected_date)
# Lock the calendar to the left panel at the top to stop it stretching
# when the main window is resized.
left_panel = QWidget()
left_layout = QVBoxLayout(left_panel)
left_layout.setContentsMargins(8, 8, 8, 8)
left_layout.addWidget(self.calendar, alignment=Qt.AlignTop)
left_layout.addWidget(self.search, alignment=Qt.AlignBottom)
left_layout.addStretch(1)
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
# This is the note-taking editor
self.editor = Editor()
# Toolbar for controlling styling
tb = ToolBar()
self.addToolBar(tb)
# 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)
split = QSplitter()
split.addWidget(left_panel)
split.addWidget(self.editor)
split.setStretchFactor(1, 1) # editor grows
container = QWidget()
lay = QVBoxLayout(container)
lay.addWidget(split)
self.setCentralWidget(container)
# Status bar for feedback
self.statusBar().showMessage("Ready", 800)
# Menu bar (File)
mb = self.menuBar()
file_menu = mb.addMenu("&Application")
act_save = QAction("&Save", self)
act_save.setShortcut("Ctrl+S")
act_save.triggered.connect(lambda: self._save_current(explicit=True))
file_menu.addAction(act_save)
act_settings = QAction("S&ettings", self)
act_settings.setShortcut("Ctrl+E")
act_settings.triggered.connect(self._open_settings)
file_menu.addAction(act_settings)
file_menu.addSeparator()
act_quit = QAction("&Quit", self)
act_quit.setShortcut("Ctrl+Q")
act_quit.triggered.connect(self.close)
file_menu.addAction(act_quit)
# Navigate menu with next/previous/today
nav_menu = mb.addMenu("&Navigate")
act_prev = QAction("Previous Day", self)
act_prev.setShortcut("Ctrl+P")
act_prev.setShortcutContext(Qt.ApplicationShortcut)
act_prev.triggered.connect(lambda: self._adjust_day(-1))
nav_menu.addAction(act_prev)
self.addAction(act_prev)
act_next = QAction("Next Day", self)
act_next.setShortcut("Ctrl+N")
act_next.setShortcutContext(Qt.ApplicationShortcut)
act_next.triggered.connect(lambda: self._adjust_day(1))
nav_menu.addAction(act_next)
self.addAction(act_next)
act_today = QAction("Today", self)
act_today.setShortcut("Ctrl+T")
act_today.setShortcutContext(Qt.ApplicationShortcut)
act_today.triggered.connect(self._adjust_today)
nav_menu.addAction(act_today)
self.addAction(act_today)
# Autosave
self._dirty = False
self._save_timer = QTimer(self)
self._save_timer.setSingleShot(True)
self._save_timer.timeout.connect(self._save_current)
self.editor.textChanged.connect(self._on_text_changed)
# First load + mark dates in calendar with content
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.
"""
try:
self.db = DBManager(self.cfg)
ok = self.db.connect()
except Exception as e:
if str(e) == "file is not a database":
error = "The key is probably incorrect."
else:
error = str(e)
QMessageBox.critical(self, "Database Error", error)
return False
return ok
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, title, message)
if dlg.exec() != QDialog.Accepted:
return False
self.cfg.key = dlg.key()
if self._try_connect():
return True
def _refresh_calendar_marks(self):
"""
Sets a bold marker on the day to indicate that text exists
for that day.
"""
fmt_bold = QTextCharFormat()
fmt_bold.setFontWeight(QFont.Weight.Bold)
# Clear previous marks
for d in getattr(self, "_marked_dates", set()):
self.calendar.setDateTextFormat(d, QTextCharFormat())
self._marked_dates = set()
try:
for date_iso in self.db.dates_with_content():
qd = QDate.fromString(date_iso, "yyyy-MM-dd")
if qd.isValid():
self.calendar.setDateTextFormat(qd, fmt_bold)
self._marked_dates.add(qd)
except Exception:
pass
# --- UI handlers ---------------------------------------------------------
def _current_date_iso(self) -> str:
d = self.calendar.selectedDate()
return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
def _load_selected_date(self, date_iso=False):
if not date_iso:
date_iso = self._current_date_iso()
try:
text = self.db.get_entry(date_iso)
except Exception as e:
QMessageBox.critical(self, "Read Error", str(e))
return
self.editor.blockSignals(True)
self.editor.setHtml(text)
self.editor.blockSignals(False)
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
self._save_timer.start(1200) # autosave after idle
def _adjust_day(self, delta: int):
"""Move selection by delta days (negative for previous)."""
d = self.calendar.selectedDate().addDays(delta)
self.calendar.setSelectedDate(d)
def _adjust_today(self):
"""Jump to today."""
today = QDate.currentDate()
self.calendar.setSelectedDate(today)
def _on_date_changed(self):
"""
When the calendar selection changes, save the previous day's note if dirty,
so we don't lose that text, then load the newly selected day.
"""
# Stop pending autosave and persist current buffer if needed
try:
self._save_timer.stop()
except Exception:
pass
prev = getattr(self, "_active_date_iso", None)
if prev and self._dirty:
self._save_date(prev, explicit=False)
# Now load the newly selected date
self._load_selected_date()
def _save_date(self, date_iso: str, explicit: bool = False):
"""
Save editor contents into the given date. Shows status on success.
explicit=True means user invoked Save: show feedback even if nothing changed.
"""
if not self._dirty and not explicit:
return
text = self.editor.toHtml()
try:
self.db.upsert_entry(date_iso, text)
except Exception as e:
QMessageBox.critical(self, "Save Error", str(e))
return
self._dirty = False
self._refresh_calendar_marks()
# Feedback in the status bar
from datetime import datetime as _dt
self.statusBar().showMessage(
f"Saved {date_iso} at {_dt.now().strftime('%H:%M:%S')}", 2000
)
def _save_current(self, explicit: bool = False):
# Delegate to _save_date for the currently selected date
self._save_date(self._current_date_iso(), explicit)
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()
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 screens 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:
pass
super().closeEvent(event)