1949 lines
72 KiB
Python
1949 lines
72 KiB
Python
from __future__ import annotations
|
|
|
|
import datetime
|
|
import os
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from PySide6.QtCore import (
|
|
QDate,
|
|
QDateTime,
|
|
QEvent,
|
|
QSettings,
|
|
QSignalBlocker,
|
|
Qt,
|
|
QTime,
|
|
QTimer,
|
|
QUrl,
|
|
Slot,
|
|
)
|
|
from PySide6.QtGui import (
|
|
QAction,
|
|
QBrush,
|
|
QColor,
|
|
QCursor,
|
|
QDesktopServices,
|
|
QFont,
|
|
QGuiApplication,
|
|
QKeySequence,
|
|
QTextCursor,
|
|
QTextListFormat,
|
|
)
|
|
from PySide6.QtWidgets import (
|
|
QApplication,
|
|
QCalendarWidget,
|
|
QDialog,
|
|
QFileDialog,
|
|
QLabel,
|
|
QMainWindow,
|
|
QMenu,
|
|
QMessageBox,
|
|
QPushButton,
|
|
QSizePolicy,
|
|
QSplitter,
|
|
QTableView,
|
|
QTabWidget,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
from . import strings
|
|
from .bug_report_dialog import BugReportDialog
|
|
from .db import DBManager
|
|
from .documents import DocumentsDialog, TodaysDocumentsWidget
|
|
from .find_bar import FindBar
|
|
from .history_dialog import HistoryDialog
|
|
from .key_prompt import KeyPrompt
|
|
from .lock_overlay import LockOverlay
|
|
from .markdown_editor import MarkdownEditor
|
|
from .pomodoro_timer import PomodoroManager
|
|
from .reminders import UpcomingRemindersWidget, ReminderWebHook
|
|
from .save_dialog import SaveDialog
|
|
from .search import Search
|
|
from .settings import APP_NAME, APP_ORG, load_db_config, save_db_config
|
|
from .settings_dialog import SettingsDialog
|
|
from .statistics_dialog import StatisticsDialog
|
|
from .tags_widget import PageTagsWidget
|
|
from .theme import ThemeManager
|
|
from .time_log import TimeLogWidget
|
|
from .toolbar import ToolBar
|
|
from .version_check import VersionChecker
|
|
|
|
|
|
class MainWindow(QMainWindow):
|
|
def __init__(self, themes: ThemeManager, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.setWindowTitle(APP_NAME)
|
|
self.setMinimumSize(1000, 650)
|
|
|
|
self.themes = themes # Store the themes manager
|
|
self.version_checker = VersionChecker(self)
|
|
|
|
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
|
|
|
|
# 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()
|
|
|
|
self.settings = QSettings(APP_ORG, APP_NAME)
|
|
|
|
# ---- 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.themes.register_calendar(self.calendar)
|
|
|
|
self.search = Search(self.db)
|
|
self.search.openDateRequested.connect(self._load_selected_date)
|
|
self.search.resultDatesChanged.connect(self._on_search_dates_changed)
|
|
|
|
# Features
|
|
self.time_log = TimeLogWidget(self.db, themes=self.themes)
|
|
|
|
self.tags = PageTagsWidget(self.db)
|
|
self.tags.tagActivated.connect(self._on_tag_activated)
|
|
self.tags.tagAdded.connect(self._on_tag_added)
|
|
|
|
self.upcoming_reminders = UpcomingRemindersWidget(self.db)
|
|
self.upcoming_reminders.reminderTriggered.connect(self._send_reminder_webhook)
|
|
self.upcoming_reminders.reminderTriggered.connect(self._show_flashing_reminder)
|
|
|
|
# When invoices change reminders (e.g. invoice paid), refresh the Reminders widget
|
|
self.time_log.remindersChanged.connect(self.upcoming_reminders.refresh)
|
|
|
|
self.pomodoro_manager = PomodoroManager(self.db, self)
|
|
|
|
# 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)
|
|
left_layout.addWidget(self.search)
|
|
left_layout.addWidget(self.upcoming_reminders)
|
|
self.todays_documents = TodaysDocumentsWidget(self.db, self._current_date_iso())
|
|
left_layout.addWidget(self.todays_documents)
|
|
left_layout.addWidget(self.time_log)
|
|
left_layout.addWidget(self.tags)
|
|
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
|
|
|
|
# Create tab widget to hold multiple editors
|
|
self.tab_widget = QTabWidget()
|
|
self.tab_widget.setTabsClosable(True)
|
|
self.tab_widget.tabCloseRequested.connect(self._close_tab)
|
|
self.tab_widget.currentChanged.connect(self._on_tab_changed)
|
|
self._prev_editor = None
|
|
|
|
# Toolbar for controlling styling
|
|
self.toolBar = ToolBar()
|
|
self.addToolBar(self.toolBar)
|
|
self._bind_toolbar()
|
|
|
|
# Create the first editor tab
|
|
self._create_new_tab()
|
|
self._prev_editor = self.editor
|
|
|
|
split = QSplitter()
|
|
split.addWidget(left_panel)
|
|
split.addWidget(self.tab_widget)
|
|
split.setStretchFactor(1, 1)
|
|
|
|
# Enable context menu on calendar for opening dates in new tabs
|
|
self.calendar.setContextMenuPolicy(Qt.CustomContextMenu)
|
|
self.calendar.customContextMenuRequested.connect(
|
|
self._show_calendar_context_menu
|
|
)
|
|
|
|
# Flag to prevent _on_date_changed when showing context menu
|
|
self._showing_context_menu = False
|
|
|
|
# Install event filter to catch right-clicks before selectionChanged fires
|
|
self.calendar.installEventFilter(self)
|
|
|
|
container = QWidget()
|
|
lay = QVBoxLayout(container)
|
|
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, themes=self.themes
|
|
)
|
|
self.centralWidget().installEventFilter(self._lock_overlay)
|
|
|
|
self._locked = False
|
|
|
|
# reset idle timer on any key press anywhere in the app
|
|
QApplication.instance().installEventFilter(self)
|
|
|
|
# Focus on the editor
|
|
self.setFocusPolicy(Qt.StrongFocus)
|
|
self.editor.setFocusPolicy(Qt.StrongFocus)
|
|
self.toolBar.setFocusPolicy(Qt.NoFocus)
|
|
for w in self.toolBar.findChildren(QWidget):
|
|
w.setFocusPolicy(Qt.NoFocus)
|
|
QGuiApplication.instance().applicationStateChanged.connect(
|
|
self._on_app_state_changed
|
|
)
|
|
|
|
# Status bar for feedback
|
|
self.statusBar().showMessage(strings._("main_window_ready"), 800)
|
|
# Add findBar and add it to the statusBar
|
|
# FindBar will get the current editor dynamically via a callable
|
|
self.findBar = FindBar(lambda: self.editor, shortcut_parent=self, parent=self)
|
|
self.statusBar().addPermanentWidget(self.findBar)
|
|
# When the findBar closes, put the caret back in the editor
|
|
self.findBar.closed.connect(self._focus_editor_now)
|
|
|
|
# Menu bar (File)
|
|
mb = self.menuBar()
|
|
file_menu = mb.addMenu("&" + strings._("file"))
|
|
act_save = QAction("&" + strings._("main_window_save_a_version"), self)
|
|
act_save.setShortcut("Ctrl+S")
|
|
act_save.triggered.connect(lambda: self._save_current(explicit=True))
|
|
file_menu.addAction(act_save)
|
|
act_history = QAction("&" + strings._("history"), self)
|
|
act_history.setShortcut("Ctrl+Shift+H")
|
|
act_history.setShortcutContext(Qt.ApplicationShortcut)
|
|
act_history.triggered.connect(self._open_history)
|
|
file_menu.addAction(act_history)
|
|
act_settings = QAction(strings._("main_window_settings_accessible_flag"), self)
|
|
act_settings.setShortcut("Ctrl+Shift+.")
|
|
act_settings.triggered.connect(self._open_settings)
|
|
file_menu.addAction(act_settings)
|
|
act_export = QAction(strings._("export_accessible_flag"), self)
|
|
act_export.setShortcut("Ctrl+Shift+E")
|
|
act_export.triggered.connect(self._export)
|
|
file_menu.addAction(act_export)
|
|
act_backup = QAction("&" + strings._("backup"), self)
|
|
act_backup.setShortcut("Ctrl+Shift+B")
|
|
act_backup.triggered.connect(self._backup)
|
|
file_menu.addAction(act_backup)
|
|
act_stats = QAction(strings._("main_window_statistics_accessible_flag"), self)
|
|
act_stats.setShortcut("Ctrl+Shift+S")
|
|
act_stats.triggered.connect(self._open_statistics)
|
|
file_menu.addAction(act_stats)
|
|
act_lock = QAction(strings._("main_window_lock_screen_accessibility"), self)
|
|
act_lock.setShortcut("Ctrl+Shift+L")
|
|
act_lock.triggered.connect(self._enter_lock)
|
|
file_menu.addAction(act_lock)
|
|
file_menu.addSeparator()
|
|
act_quit = QAction("&" + strings._("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("&" + strings._("navigate"))
|
|
act_prev = QAction(strings._("previous_day"), self)
|
|
act_prev.setShortcut("Ctrl+Shift+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(strings._("next_day"), self)
|
|
act_next.setShortcut("Ctrl+Shift+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(strings._("today"), self)
|
|
act_today.setShortcut("Ctrl+Shift+T")
|
|
act_today.setShortcutContext(Qt.ApplicationShortcut)
|
|
act_today.triggered.connect(self._adjust_today)
|
|
nav_menu.addAction(act_today)
|
|
self.addAction(act_today)
|
|
|
|
act_close_tab = QAction(strings._("close_tab"), self)
|
|
act_close_tab.setShortcut("Ctrl+W")
|
|
act_close_tab.setShortcutContext(Qt.ApplicationShortcut)
|
|
act_close_tab.triggered.connect(self._close_current_tab)
|
|
nav_menu.addAction(act_close_tab)
|
|
self.addAction(act_close_tab)
|
|
|
|
act_find = QAction(strings._("find_on_page"), self)
|
|
act_find.setShortcut(QKeySequence.Find)
|
|
act_find.triggered.connect(self.findBar.show_bar)
|
|
nav_menu.addAction(act_find)
|
|
self.addAction(act_find)
|
|
|
|
act_find_next = QAction(strings._("find_next"), self)
|
|
act_find_next.setShortcut(QKeySequence.FindNext)
|
|
act_find_next.triggered.connect(self.findBar.find_next)
|
|
nav_menu.addAction(act_find_next)
|
|
self.addAction(act_find_next)
|
|
|
|
act_find_prev = QAction(strings._("find_previous"), self)
|
|
act_find_prev.setShortcut(QKeySequence.FindPrevious)
|
|
act_find_prev.triggered.connect(self.findBar.find_prev)
|
|
nav_menu.addAction(act_find_prev)
|
|
self.addAction(act_find_prev)
|
|
|
|
# Help menu with drop-down
|
|
help_menu = mb.addMenu("&" + strings._("help"))
|
|
act_docs = QAction(strings._("documentation"), self)
|
|
act_docs.setShortcut("Ctrl+Shift+D")
|
|
act_docs.setShortcutContext(Qt.ApplicationShortcut)
|
|
act_docs.triggered.connect(self._open_docs)
|
|
help_menu.addAction(act_docs)
|
|
self.addAction(act_docs)
|
|
act_bugs = QAction(strings._("report_a_bug"), self)
|
|
act_bugs.setShortcut("Ctrl+Shift+R")
|
|
act_bugs.setShortcutContext(Qt.ApplicationShortcut)
|
|
act_bugs.triggered.connect(self._open_bugs)
|
|
help_menu.addAction(act_bugs)
|
|
self.addAction(act_bugs)
|
|
act_version = QAction(strings._("version"), self)
|
|
act_version.setShortcut("Ctrl+Shift+V")
|
|
act_version.setShortcutContext(Qt.ApplicationShortcut)
|
|
act_version.triggered.connect(self._open_version)
|
|
help_menu.addAction(act_version)
|
|
self.addAction(act_version)
|
|
|
|
# Autosave
|
|
self._dirty = False
|
|
self._save_timer = QTimer(self)
|
|
self._save_timer.setSingleShot(True)
|
|
self._save_timer.timeout.connect(self._save_current)
|
|
|
|
# Reminders / alarms
|
|
self._reminder_timers: list[QTimer] = []
|
|
|
|
# First load + mark dates in calendar with content
|
|
if not self._load_unchecked_todos():
|
|
self._load_selected_date()
|
|
self._refresh_calendar_marks()
|
|
|
|
# Hide tags and time log widgets if not enabled
|
|
if not self.cfg.tags:
|
|
self.tags.hide()
|
|
if not self.cfg.time_log:
|
|
self.time_log.hide()
|
|
self.toolBar.actTimer.setVisible(False)
|
|
if not self.cfg.reminders:
|
|
self.upcoming_reminders.hide()
|
|
self.toolBar.actAlarm.setVisible(False)
|
|
if not self.cfg.documents:
|
|
self.todays_documents.hide()
|
|
self.toolBar.actDocuments.setVisible(False)
|
|
|
|
# Restore window position from settings
|
|
self._restore_window_position()
|
|
|
|
# re-apply all runtime color tweaks when theme changes
|
|
self.themes.themeChanged.connect(lambda _t: self._retheme_overrides())
|
|
|
|
# apply once on startup so links / calendar colors are set immediately
|
|
self._retheme_overrides()
|
|
|
|
# Build any alarms for *today* from stored markdown
|
|
self._rebuild_reminders_for_today()
|
|
|
|
# Rollover unchecked todos automatically when the calendar day changes
|
|
self._day_change_timer = QTimer(self)
|
|
self._day_change_timer.setSingleShot(True)
|
|
self._day_change_timer.timeout.connect(self._on_day_changed)
|
|
self._schedule_next_day_change()
|
|
|
|
# Ensure toolbar is definitely visible
|
|
self.toolBar.setVisible(True)
|
|
|
|
@property
|
|
def editor(self) -> MarkdownEditor | None:
|
|
"""Get the currently active editor."""
|
|
return self.tab_widget.currentWidget()
|
|
|
|
def _call_editor(self, method_name, *args):
|
|
"""
|
|
Call the relevant method of the MarkdownEditor class on bind
|
|
"""
|
|
getattr(self.editor, method_name)(*args)
|
|
|
|
# ----------- Database connection/key management methods ------------ #
|
|
|
|
def _try_connect(self) -> bool:
|
|
"""
|
|
Try to connect to the database.
|
|
"""
|
|
try:
|
|
self.db = DBManager(self.cfg)
|
|
ok = self.db.connect()
|
|
return ok
|
|
except Exception as e:
|
|
if str(e) == "file is not a database":
|
|
error = strings._("db_key_incorrect")
|
|
else:
|
|
error = str(e)
|
|
QMessageBox.critical(self, strings._("db_database_error"), error)
|
|
return False
|
|
|
|
def _prompt_for_key_until_valid(self, first_time: bool) -> bool:
|
|
"""
|
|
Prompt for the SQLCipher key.
|
|
"""
|
|
if first_time:
|
|
title = strings._("set_an_encryption_key")
|
|
message = strings._("set_an_encryption_key_explanation")
|
|
else:
|
|
title = strings._("unlock_encrypted_notebook")
|
|
message = strings._("unlock_encrypted_notebook_explanation")
|
|
while True:
|
|
dlg = KeyPrompt(
|
|
self, title, message, initial_db_path=self.cfg.path, show_db_change=True
|
|
)
|
|
if dlg.exec() != QDialog.Accepted:
|
|
return False
|
|
self.cfg.key = dlg.key()
|
|
|
|
# Update DB path if the user changed it
|
|
new_path = dlg.db_path()
|
|
if new_path is not None and new_path != self.cfg.path:
|
|
self.cfg.path = new_path
|
|
# Persist immediately so next run is pre-filled with this file
|
|
save_db_config(self.cfg)
|
|
|
|
if self._try_connect():
|
|
return True
|
|
|
|
# ----------------- Tab and date management ----------------- #
|
|
|
|
def _current_date_iso(self) -> str:
|
|
d = self.calendar.selectedDate()
|
|
return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
|
|
|
|
def _date_key(self, qd: QDate) -> tuple[int, int, int]:
|
|
return (qd.year(), qd.month(), qd.day())
|
|
|
|
def _index_for_date_insert(self, date: QDate) -> int:
|
|
"""Return the index where a tab for `date` should be inserted (ascending order)."""
|
|
key = self._date_key(date)
|
|
for i in range(self.tab_widget.count()):
|
|
w = self.tab_widget.widget(i)
|
|
d = getattr(w, "current_date", None)
|
|
if isinstance(d, QDate) and d.isValid():
|
|
if self._date_key(d) > key:
|
|
return i
|
|
return self.tab_widget.count()
|
|
|
|
def _reorder_tabs_by_date(self):
|
|
"""Reorder existing tabs by their date (ascending)."""
|
|
bar = self.tab_widget.tabBar()
|
|
dated, undated = [], []
|
|
|
|
for i in range(self.tab_widget.count()):
|
|
w = self.tab_widget.widget(i)
|
|
d = getattr(w, "current_date", None)
|
|
if isinstance(d, QDate) and d.isValid():
|
|
dated.append((d, w))
|
|
else:
|
|
undated.append(w)
|
|
|
|
dated.sort(key=lambda t: self._date_key(t[0]))
|
|
|
|
with QSignalBlocker(self.tab_widget):
|
|
# Update labels to yyyy-MM-dd
|
|
for d, w in dated:
|
|
idx = self.tab_widget.indexOf(w)
|
|
if idx != -1:
|
|
self.tab_widget.setTabText(idx, d.toString("yyyy-MM-dd"))
|
|
|
|
# Move dated tabs into target positions 0..len(dated)-1
|
|
for target_pos, (_, w) in enumerate(dated):
|
|
cur = self.tab_widget.indexOf(w)
|
|
if cur != -1 and cur != target_pos:
|
|
bar.moveTab(cur, target_pos)
|
|
|
|
# Keep any undated pages (if they ever exist) after the dated ones
|
|
start = len(dated)
|
|
for offset, w in enumerate(undated):
|
|
cur = self.tab_widget.indexOf(w)
|
|
target = start + offset
|
|
if cur != -1 and cur != target:
|
|
bar.moveTab(cur, target)
|
|
|
|
def _tab_index_for_date(self, date: QDate) -> int:
|
|
"""Return the index of the tab showing `date`, or -1 if none."""
|
|
iso = date.toString("yyyy-MM-dd")
|
|
for i in range(self.tab_widget.count()):
|
|
w = self.tab_widget.widget(i)
|
|
if (
|
|
hasattr(w, "current_date")
|
|
and w.current_date.toString("yyyy-MM-dd") == iso
|
|
):
|
|
return i
|
|
return -1
|
|
|
|
def _open_date_in_tab(self, date: QDate):
|
|
"""Focus existing tab for `date`, or create it if needed. Returns the editor."""
|
|
idx = self._tab_index_for_date(date)
|
|
if idx != -1:
|
|
self.tab_widget.setCurrentIndex(idx)
|
|
# keep calendar selection in sync (don't trigger load)
|
|
from PySide6.QtCore import QSignalBlocker
|
|
|
|
with QSignalBlocker(self.calendar):
|
|
self.calendar.setSelectedDate(date)
|
|
QTimer.singleShot(0, self._focus_editor_now)
|
|
return self.tab_widget.widget(idx)
|
|
# not open yet -> create
|
|
return self._create_new_tab(date)
|
|
|
|
def _create_new_tab(self, date: QDate | None = None) -> MarkdownEditor:
|
|
"""Create a new editor tab and return the editor instance."""
|
|
if date is None:
|
|
date = self.calendar.selectedDate()
|
|
|
|
# Deduplicate: if already open, just jump there
|
|
existing = self._tab_index_for_date(date)
|
|
if existing != -1:
|
|
self.tab_widget.setCurrentIndex(existing)
|
|
return self.tab_widget.widget(existing)
|
|
|
|
editor = MarkdownEditor(self.themes)
|
|
|
|
# Apply user's preferred font size
|
|
self._apply_font_size(editor)
|
|
|
|
# Set up the editor's event connections
|
|
editor.currentCharFormatChanged.connect(lambda _f: self._sync_toolbar())
|
|
editor.cursorPositionChanged.connect(self._sync_toolbar)
|
|
editor.textChanged.connect(self._on_text_changed)
|
|
|
|
# Set tab title
|
|
tab_title = date.toString("yyyy-MM-dd")
|
|
|
|
# Add the tab
|
|
index = self.tab_widget.addTab(editor, tab_title)
|
|
self.tab_widget.setCurrentIndex(index)
|
|
|
|
# Load the date's content
|
|
self._load_date_into_editor(date)
|
|
|
|
# Store the date with the editor so we can save it later
|
|
editor.current_date = date
|
|
|
|
# Insert at sorted position
|
|
tab_title = date.toString("yyyy-MM-dd")
|
|
pos = self._index_for_date_insert(date)
|
|
index = self.tab_widget.insertTab(pos, editor, tab_title)
|
|
self.tab_widget.setCurrentIndex(index)
|
|
|
|
return editor
|
|
|
|
def _close_tab(self, index: int):
|
|
"""Close a tab at the given index."""
|
|
if self.tab_widget.count() <= 1:
|
|
# Don't close the last tab
|
|
return
|
|
|
|
editor = self.tab_widget.widget(index)
|
|
if editor:
|
|
# Save before closing
|
|
self._save_editor_content(editor)
|
|
self._dirty = False
|
|
|
|
self.tab_widget.removeTab(index)
|
|
|
|
def _close_current_tab(self):
|
|
"""Close the currently active tab via shortcuts (Ctrl+W)."""
|
|
idx = self.tab_widget.currentIndex()
|
|
if idx >= 0:
|
|
self._close_tab(idx)
|
|
|
|
def _on_tab_changed(self, index: int):
|
|
"""Handle tab change - reconnect toolbar and sync UI."""
|
|
if index < 0:
|
|
return
|
|
|
|
# If we had pending edits, flush them from the tab we're leaving.
|
|
try:
|
|
self._save_timer.stop() # avoid a pending autosave targeting the *new* tab
|
|
except Exception:
|
|
pass
|
|
|
|
if getattr(self, "_prev_editor", None) is not None and self._dirty:
|
|
self._save_editor_content(self._prev_editor)
|
|
self._dirty = False # we just saved the edited tab
|
|
|
|
# Update calendar selection to match the tab
|
|
editor = self.tab_widget.widget(index)
|
|
if editor and hasattr(editor, "current_date"):
|
|
with QSignalBlocker(self.calendar):
|
|
self.calendar.setSelectedDate(editor.current_date)
|
|
|
|
# update per-page tags for the active tab
|
|
date_iso = editor.current_date.toString("yyyy-MM-dd")
|
|
self._update_tag_views_for_date(date_iso)
|
|
|
|
# Reconnect toolbar to new active editor
|
|
self._sync_toolbar()
|
|
|
|
# Focus the editor
|
|
QTimer.singleShot(0, self._focus_editor_now)
|
|
|
|
# Remember this as the "previous" editor for next switch
|
|
self._prev_editor = editor
|
|
|
|
def _date_from_calendar_pos(self, pos) -> QDate | None:
|
|
"""Translate a QCalendarWidget local pos to the QDate under the cursor."""
|
|
view: QTableView = self.calendar.findChild(
|
|
QTableView, "qt_calendar_calendarview"
|
|
)
|
|
if view is None:
|
|
return None
|
|
|
|
# Map calendar-local pos -> viewport pos
|
|
vp_pos = view.viewport().mapFrom(self.calendar, pos)
|
|
idx = view.indexAt(vp_pos)
|
|
if not idx.isValid():
|
|
return None
|
|
|
|
model = view.model()
|
|
|
|
# Account for optional headers
|
|
start_col = (
|
|
0
|
|
if self.calendar.verticalHeaderFormat() == QCalendarWidget.NoVerticalHeader
|
|
else 1
|
|
)
|
|
start_row = (
|
|
0
|
|
if self.calendar.horizontalHeaderFormat()
|
|
== QCalendarWidget.NoHorizontalHeader
|
|
else 1
|
|
)
|
|
|
|
# Find index of day 1 (first cell belonging to current month)
|
|
first_index = None
|
|
for r in range(start_row, model.rowCount()):
|
|
for c in range(start_col, model.columnCount()):
|
|
if model.index(r, c).data() == 1:
|
|
first_index = model.index(r, c)
|
|
break
|
|
if first_index:
|
|
break
|
|
if first_index is None:
|
|
return None
|
|
|
|
# Find index of the last day of the current month
|
|
last_day = (
|
|
QDate(self.calendar.yearShown(), self.calendar.monthShown(), 1)
|
|
.addMonths(1)
|
|
.addDays(-1)
|
|
.day()
|
|
)
|
|
last_index = None
|
|
for r in range(model.rowCount() - 1, first_index.row() - 1, -1):
|
|
for c in range(model.columnCount() - 1, start_col - 1, -1):
|
|
if model.index(r, c).data() == last_day:
|
|
last_index = model.index(r, c)
|
|
break
|
|
if last_index:
|
|
break
|
|
if last_index is None:
|
|
return None
|
|
|
|
# Determine if clicked cell belongs to prev/next month or current
|
|
day = int(idx.data())
|
|
year = self.calendar.yearShown()
|
|
month = self.calendar.monthShown()
|
|
|
|
before_first = (idx.row() < first_index.row()) or (
|
|
idx.row() == first_index.row() and idx.column() < first_index.column()
|
|
)
|
|
after_last = (idx.row() > last_index.row()) or (
|
|
idx.row() == last_index.row() and idx.column() > last_index.column()
|
|
)
|
|
|
|
if before_first:
|
|
if month == 1:
|
|
month = 12
|
|
year -= 1
|
|
else:
|
|
month -= 1
|
|
elif after_last:
|
|
if month == 12:
|
|
month = 1
|
|
year += 1
|
|
else:
|
|
month += 1
|
|
|
|
qd = QDate(year, month, day)
|
|
return qd if qd.isValid() else None
|
|
|
|
def _show_calendar_context_menu(self, pos):
|
|
self._showing_context_menu = True # so selectionChanged handler doesn't fire
|
|
clicked_date = self._date_from_calendar_pos(pos)
|
|
|
|
menu = QMenu(self)
|
|
open_in_new_tab_action = menu.addAction(strings._("open_in_new_tab"))
|
|
action = menu.exec_(self.calendar.mapToGlobal(pos))
|
|
|
|
self._showing_context_menu = False
|
|
|
|
if action == open_in_new_tab_action and clicked_date and clicked_date.isValid():
|
|
self._open_date_in_tab(clicked_date)
|
|
|
|
def _load_selected_date(self, date_iso=False, extra_data=False):
|
|
"""Load a date into the current editor"""
|
|
if not date_iso:
|
|
date_iso = self._current_date_iso()
|
|
|
|
qd = QDate.fromString(date_iso, "yyyy-MM-dd")
|
|
current_index = self.tab_widget.currentIndex()
|
|
|
|
# Check if this date is already open in a *different* tab
|
|
existing_idx = self._tab_index_for_date(qd)
|
|
if existing_idx != -1 and existing_idx != current_index:
|
|
# Date is already open in another tab - just switch to that tab
|
|
self.tab_widget.setCurrentIndex(existing_idx)
|
|
# Keep calendar in sync
|
|
with QSignalBlocker(self.calendar):
|
|
self.calendar.setSelectedDate(qd)
|
|
QTimer.singleShot(0, self._focus_editor_now)
|
|
return
|
|
|
|
# Date not open in any other tab - load it into current tab
|
|
# Keep calendar in sync
|
|
with QSignalBlocker(self.calendar):
|
|
self.calendar.setSelectedDate(qd)
|
|
|
|
self._load_date_into_editor(qd, extra_data)
|
|
self.editor.current_date = qd
|
|
|
|
# Update tab title
|
|
if current_index >= 0:
|
|
self.tab_widget.setTabText(current_index, date_iso)
|
|
|
|
# Keep tabs sorted by date
|
|
self._reorder_tabs_by_date()
|
|
|
|
# sync tags
|
|
self._update_tag_views_for_date(date_iso)
|
|
|
|
def _load_date_into_editor(self, date: QDate, extra_data=False):
|
|
"""Load a specific date's content into a given editor."""
|
|
date_iso = date.toString("yyyy-MM-dd")
|
|
text = self.db.get_entry(date_iso)
|
|
if extra_data:
|
|
# Append extra data as markdown
|
|
if text and not text.endswith("\n"):
|
|
text += "\n"
|
|
text += extra_data
|
|
# Force a save now so we don't lose it.
|
|
self._set_editor_markdown_preserve_view(text)
|
|
self._dirty = True
|
|
self._save_date(date_iso, True)
|
|
|
|
self._set_editor_markdown_preserve_view(text)
|
|
self._dirty = False
|
|
|
|
def _set_editor_markdown_preserve_view(self, markdown: str):
|
|
|
|
# Save caret/selection and scroll
|
|
cur = self.editor.textCursor()
|
|
old_pos, old_anchor = cur.position(), cur.anchor()
|
|
v = self.editor.verticalScrollBar().value()
|
|
h = self.editor.horizontalScrollBar().value()
|
|
|
|
# Only touch the doc if it actually changed
|
|
self.editor.blockSignals(True)
|
|
if self.editor.to_markdown() != markdown:
|
|
self.editor.from_markdown(markdown)
|
|
self.editor.blockSignals(False)
|
|
|
|
# Restore scroll first
|
|
self.editor.verticalScrollBar().setValue(v)
|
|
self.editor.horizontalScrollBar().setValue(h)
|
|
|
|
# Restore caret/selection (bounded to new doc length)
|
|
doc_length = self.editor.document().characterCount() - 1
|
|
old_pos = min(old_pos, doc_length)
|
|
old_anchor = min(old_anchor, doc_length)
|
|
|
|
cur = self.editor.textCursor()
|
|
cur.setPosition(old_anchor)
|
|
mode = (
|
|
QTextCursor.KeepAnchor if old_anchor != old_pos else QTextCursor.MoveAnchor
|
|
)
|
|
cur.setPosition(old_pos, mode)
|
|
self.editor.setTextCursor(cur)
|
|
|
|
# Refresh highlights if the theme changed
|
|
if hasattr(self, "findBar"):
|
|
self.findBar.refresh()
|
|
|
|
def _save_editor_content(self, editor: MarkdownEditor):
|
|
"""Save a specific editor's content to its associated date."""
|
|
# Skip if DB is missing or not connected somehow.
|
|
if not getattr(self, "db", None) or getattr(self.db, "conn", None) is None:
|
|
return
|
|
if not hasattr(editor, "current_date"):
|
|
return
|
|
date_iso = editor.current_date.toString("yyyy-MM-dd")
|
|
md = editor.to_markdown()
|
|
self.db.save_new_version(date_iso, md, note=strings._("autosave"))
|
|
|
|
def _on_text_changed(self):
|
|
self._dirty = True
|
|
self._save_timer.start(5000) # 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._create_new_tab(today)
|
|
|
|
def _rollover_target_date(self, day: QDate) -> QDate:
|
|
"""
|
|
Given a 'new day' (system date), return the date we should move
|
|
unfinished todos *to*.
|
|
|
|
By default, if the new day is Saturday or Sunday we skip ahead to the
|
|
next Monday (i.e., "next available weekday"). If the optional setting
|
|
`move_todos_include_weekends` is enabled, we move to the very next day
|
|
even if it's a weekend.
|
|
"""
|
|
if getattr(self.cfg, "move_todos_include_weekends", False):
|
|
return day
|
|
# Qt: Monday=1 ... Sunday=7
|
|
dow = day.dayOfWeek()
|
|
if dow >= 6: # Saturday (6) or Sunday (7)
|
|
return day.addDays(8 - dow) # 6 -> +2, 7 -> +1 (next Monday)
|
|
return day
|
|
|
|
def _schedule_next_day_change(self) -> None:
|
|
"""
|
|
Schedule a one-shot timer to fire shortly after the next midnight.
|
|
"""
|
|
now = QDateTime.currentDateTime()
|
|
tomorrow = now.date().addDays(1)
|
|
# A couple of minutes after midnight to be safe
|
|
next_run = QDateTime(tomorrow, QTime(0, 2))
|
|
msecs = max(60_000, now.msecsTo(next_run)) # at least 1 minute
|
|
self._day_change_timer.start(msecs)
|
|
|
|
@Slot()
|
|
def _on_day_changed(self) -> None:
|
|
"""
|
|
Called when we've crossed into a new calendar day (according to the timer).
|
|
Re-runs the rollover logic and refreshes the UI.
|
|
"""
|
|
# Make the calendar show the *real* new day first
|
|
today = QDate.currentDate()
|
|
with QSignalBlocker(self.calendar):
|
|
self.calendar.setSelectedDate(today)
|
|
|
|
# Same logic as on startup
|
|
if not self._load_unchecked_todos():
|
|
self._load_selected_date()
|
|
|
|
self._refresh_calendar_marks()
|
|
self._rebuild_reminders_for_today()
|
|
self._schedule_next_day_change()
|
|
|
|
def _load_unchecked_todos(self, days_back: int = 7) -> bool:
|
|
"""
|
|
Move unchecked checkbox items from the last `days_back` days
|
|
into the rollover target date (today, or next Monday if today
|
|
is a weekend).
|
|
|
|
Returns True if any items were moved, False otherwise.
|
|
"""
|
|
if not getattr(self.cfg, "move_todos", False):
|
|
return False
|
|
|
|
if not getattr(self, "db", None):
|
|
return False
|
|
|
|
today = QDate.currentDate()
|
|
target_date = self._rollover_target_date(today)
|
|
target_iso = target_date.toString("yyyy-MM-dd")
|
|
|
|
# Regexes for markdown headings and checkboxes
|
|
heading_re = re.compile(r"^\s{0,3}(#+)\s+(.*)$")
|
|
unchecked_re = re.compile(r"^\s*-\s*\[[\s☐]\]\s+")
|
|
|
|
def _normalize_heading(text: str) -> str:
|
|
"""
|
|
Strip trailing closing hashes and whitespace, e.g.
|
|
"## Foo ###" -> "Foo"
|
|
"""
|
|
text = text.strip()
|
|
text = re.sub(r"\s+#+\s*$", "", text)
|
|
return text.strip()
|
|
|
|
def _insert_todos_under_heading(
|
|
target_lines: list[str],
|
|
heading_level: int,
|
|
heading_text: str,
|
|
todos: list[str],
|
|
) -> list[str]:
|
|
"""Ensure a heading exists and append todos to the end of its section."""
|
|
normalized = _normalize_heading(heading_text)
|
|
|
|
# 1) Find existing heading with same text (any level)
|
|
start_idx = None
|
|
effective_level = None
|
|
for idx, line in enumerate(target_lines):
|
|
m = heading_re.match(line)
|
|
if not m:
|
|
continue
|
|
level = len(m.group(1))
|
|
text = _normalize_heading(m.group(2))
|
|
if text == normalized:
|
|
start_idx = idx
|
|
effective_level = level
|
|
break
|
|
|
|
# 2) If not found, create a new heading at the end
|
|
if start_idx is None:
|
|
if target_lines and target_lines[-1].strip():
|
|
target_lines.append("") # blank line before new heading
|
|
target_lines.append(f"{'#' * heading_level} {heading_text}")
|
|
start_idx = len(target_lines) - 1
|
|
effective_level = heading_level
|
|
|
|
# 3) Find the end of this heading's section
|
|
end_idx = len(target_lines)
|
|
for i in range(start_idx + 1, len(target_lines)):
|
|
m = heading_re.match(target_lines[i])
|
|
if m and len(m.group(1)) <= effective_level:
|
|
end_idx = i
|
|
break
|
|
|
|
# 4) Insert before any trailing blank lines in the section
|
|
insert_at = end_idx
|
|
while (
|
|
insert_at > start_idx + 1 and target_lines[insert_at - 1].strip() == ""
|
|
):
|
|
insert_at -= 1
|
|
|
|
for todo in todos:
|
|
target_lines.insert(insert_at, todo)
|
|
insert_at += 1
|
|
|
|
return target_lines
|
|
|
|
# Collect moved todos as (heading_info, item_text)
|
|
# heading_info is either None or (level, heading_text)
|
|
moved_items: list[tuple[tuple[int, str] | None, str]] = []
|
|
any_moved = False
|
|
|
|
# Look back N days (yesterday = 1, up to `days_back`)
|
|
for delta in range(1, days_back + 1):
|
|
src_date = today.addDays(-delta)
|
|
src_iso = src_date.toString("yyyy-MM-dd")
|
|
text = self.db.get_entry(src_iso)
|
|
if not text:
|
|
continue
|
|
|
|
lines = text.split("\n")
|
|
remaining_lines: list[str] = []
|
|
moved_from_this_day = False
|
|
current_heading: tuple[int, str] | None = None
|
|
|
|
for line in lines:
|
|
# Track the last seen heading (# / ## / ###)
|
|
m_head = heading_re.match(line)
|
|
if m_head:
|
|
level = len(m_head.group(1))
|
|
heading_text = _normalize_heading(m_head.group(2))
|
|
if level <= 3:
|
|
current_heading = (level, heading_text)
|
|
# Keep headings in the original day
|
|
remaining_lines.append(line)
|
|
continue
|
|
|
|
# Unchecked markdown checkboxes: "- [ ] " or "- [☐] "
|
|
if unchecked_re.match(line):
|
|
item_text = unchecked_re.sub("", line)
|
|
moved_items.append((current_heading, item_text))
|
|
moved_from_this_day = True
|
|
any_moved = True
|
|
else:
|
|
remaining_lines.append(line)
|
|
|
|
if moved_from_this_day:
|
|
modified_text = "\n".join(remaining_lines)
|
|
# Save the cleaned-up source day
|
|
self.db.save_new_version(
|
|
src_iso,
|
|
modified_text,
|
|
strings._("unchecked_checkbox_items_moved_to_next_day"),
|
|
)
|
|
|
|
if not any_moved:
|
|
return False
|
|
|
|
# --- Merge all moved items into the *target* date ---
|
|
|
|
target_text = self.db.get_entry(target_iso) or ""
|
|
target_lines = target_text.split("\n") if target_text else []
|
|
|
|
by_heading: dict[tuple[int, str], list[str]] = {}
|
|
plain_items: list[str] = []
|
|
|
|
for heading_info, item_text in moved_items:
|
|
todo_line = f"- [ ] {item_text}"
|
|
if heading_info is None:
|
|
# No heading above this checkbox in the source: behave as before
|
|
plain_items.append(todo_line)
|
|
else:
|
|
by_heading.setdefault(heading_info, []).append(todo_line)
|
|
|
|
# First insert all items that have headings
|
|
for (level, heading_text), todos in by_heading.items():
|
|
target_lines = _insert_todos_under_heading(
|
|
target_lines, level, heading_text, todos
|
|
)
|
|
|
|
# Then append all items without headings at the end, like before
|
|
if plain_items:
|
|
if target_lines and target_lines[-1].strip():
|
|
target_lines.append("") # one blank line before the "unsectioned" todos
|
|
target_lines.extend(plain_items)
|
|
|
|
new_target_text = "\n".join(target_lines)
|
|
if not new_target_text.endswith("\n"):
|
|
new_target_text += "\n"
|
|
|
|
# Save the updated target date and load it into the editor
|
|
self.db.save_new_version(
|
|
target_iso,
|
|
new_target_text,
|
|
strings._("unchecked_checkbox_items_moved_to_next_day"),
|
|
)
|
|
self._load_selected_date(target_iso)
|
|
return True
|
|
|
|
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 into current tab.
|
|
"""
|
|
# Skip if we're showing a context menu (right-click shouldn't load dates)
|
|
if getattr(self, "_showing_context_menu", False):
|
|
return
|
|
|
|
# Stop pending autosave and persist current buffer if needed
|
|
try:
|
|
self._save_timer.stop()
|
|
except Exception:
|
|
pass
|
|
|
|
# Save the current editor's content if dirty
|
|
if hasattr(self.editor, "current_date") and self._dirty:
|
|
prev_date_iso = self.editor.current_date.toString("yyyy-MM-dd")
|
|
self._save_date(prev_date_iso, explicit=False)
|
|
|
|
# Now load the newly selected date
|
|
new_date = self.calendar.selectedDate()
|
|
current_index = self.tab_widget.currentIndex()
|
|
|
|
# Check if this date is already open in a *different* tab
|
|
existing_idx = self._tab_index_for_date(new_date)
|
|
if existing_idx != -1 and existing_idx != current_index:
|
|
# Date is already open in another tab - just switch to that tab
|
|
self.tab_widget.setCurrentIndex(existing_idx)
|
|
QTimer.singleShot(0, self._focus_editor_now)
|
|
return
|
|
|
|
# Date not open in any other tab - load it into current tab
|
|
self._load_date_into_editor(new_date)
|
|
self.editor.current_date = new_date
|
|
|
|
# Update tab title
|
|
if current_index >= 0:
|
|
self.tab_widget.setTabText(current_index, new_date.toString("yyyy-MM-dd"))
|
|
|
|
# Update tags for the newly loaded page
|
|
date_iso = new_date.toString("yyyy-MM-dd")
|
|
self._update_tag_views_for_date(date_iso)
|
|
|
|
# Keep tabs sorted by date
|
|
self._reorder_tabs_by_date()
|
|
|
|
def _save_date(self, date_iso: str, explicit: bool = False, note: str = "autosave"):
|
|
"""
|
|
Save editor contents into the given date. Shows status on success.
|
|
explicit=True means user invoked Save: show feedback even if nothing changed.
|
|
"""
|
|
# Bail out if there is no DB connection (can happen during construction/teardown)
|
|
if not getattr(self.db, "conn", None):
|
|
return
|
|
|
|
if not self._dirty and not explicit:
|
|
return
|
|
text = self.editor.to_markdown() if hasattr(self, "editor") else ""
|
|
self.db.save_new_version(date_iso, text, note)
|
|
self._dirty = False
|
|
self._refresh_calendar_marks()
|
|
# Feedback in the status bar
|
|
from datetime import datetime as _dt
|
|
|
|
self.statusBar().showMessage(
|
|
strings._("saved") + f" {date_iso}: {_dt.now().strftime('%H:%M:%S')}", 2000
|
|
)
|
|
|
|
def _save_current(self, explicit: bool = False):
|
|
"""Save the current editor's content."""
|
|
try:
|
|
self._save_timer.stop()
|
|
except Exception:
|
|
pass
|
|
|
|
if explicit:
|
|
# Prompt for a note
|
|
dlg = SaveDialog(self)
|
|
if dlg.exec() != QDialog.Accepted:
|
|
return
|
|
note = dlg.note_text()
|
|
else:
|
|
note = strings._("autosave")
|
|
# Save the current editor's date
|
|
date_iso = self.editor.current_date.toString("yyyy-MM-dd")
|
|
self._save_date(date_iso, explicit, note)
|
|
try:
|
|
self._save_timer.start()
|
|
except Exception:
|
|
pass
|
|
|
|
# ----------------- Some theme helpers -------------------#
|
|
def _apply_font_size(self, editor: MarkdownEditor) -> None:
|
|
"""Apply the saved font size to a newly created editor."""
|
|
size = self.cfg.font_size
|
|
editor.qfont.setPointSize(size)
|
|
editor.setFont(editor.qfont)
|
|
self.cfg.font_size = size
|
|
# save size to settings
|
|
cfg = load_db_config()
|
|
cfg.font_size = self.cfg.font_size
|
|
save_db_config(cfg)
|
|
|
|
def _retheme_overrides(self):
|
|
self._apply_search_highlights(getattr(self, "_search_highlighted_dates", set()))
|
|
self.calendar.update()
|
|
self.editor.viewport().update()
|
|
|
|
# --------------- Search sidebar/results helpers ---------------- #
|
|
|
|
def _on_search_dates_changed(self, date_strs: list[str]):
|
|
dates = set()
|
|
for ds in date_strs or []:
|
|
qd = QDate.fromString(ds, "yyyy-MM-dd")
|
|
if qd.isValid():
|
|
dates.add(qd)
|
|
self._apply_search_highlights(dates)
|
|
|
|
def _apply_search_highlights(self, dates: set):
|
|
pal = self.palette()
|
|
base = pal.base().color()
|
|
hi = pal.highlight().color()
|
|
# Blend highlight with base so it looks soft in both modes
|
|
blend = QColor(
|
|
(2 * hi.red() + base.red()) // 3,
|
|
(2 * hi.green() + base.green()) // 3,
|
|
(2 * hi.blue() + base.blue()) // 3,
|
|
)
|
|
yellow = QBrush(blend)
|
|
old = getattr(self, "_search_highlighted_dates", set())
|
|
|
|
for d in old - dates: # clear removed
|
|
fmt = self.calendar.dateTextFormat(d)
|
|
fmt.setBackground(Qt.transparent)
|
|
self.calendar.setDateTextFormat(d, fmt)
|
|
|
|
for d in dates: # apply new/current
|
|
fmt = self.calendar.dateTextFormat(d)
|
|
fmt.setBackground(yellow)
|
|
self.calendar.setDateTextFormat(d, fmt)
|
|
|
|
self._search_highlighted_dates = dates
|
|
|
|
def _refresh_calendar_marks(self):
|
|
"""Make days with entries bold, but keep any search highlight backgrounds."""
|
|
for d in getattr(self, "_marked_dates", set()):
|
|
fmt = self.calendar.dateTextFormat(d)
|
|
fmt.setFontWeight(QFont.Weight.Normal) # remove bold only
|
|
self.calendar.setDateTextFormat(d, fmt)
|
|
self._marked_dates = set()
|
|
if self.db.conn is not None:
|
|
for date_iso in self.db.dates_with_content():
|
|
qd = QDate.fromString(date_iso, "yyyy-MM-dd")
|
|
if qd.isValid():
|
|
fmt = self.calendar.dateTextFormat(qd)
|
|
fmt.setFontWeight(QFont.Weight.Bold) # add bold only
|
|
self.calendar.setDateTextFormat(qd, fmt)
|
|
self._marked_dates.add(qd)
|
|
|
|
# -------------------- UI handlers ------------------- #
|
|
|
|
def _bind_toolbar(self):
|
|
if getattr(self, "_toolbar_bound", False):
|
|
return
|
|
tb = self.toolBar
|
|
|
|
# keep refs so we never create new lambdas (prevents accidental dupes)
|
|
self._tb_bold = lambda: self._call_editor("apply_weight")
|
|
self._tb_italic = lambda: self._call_editor("apply_italic")
|
|
self._tb_strike = lambda: self._call_editor("apply_strikethrough")
|
|
self._tb_code = lambda: self._call_editor("apply_code")
|
|
self._tb_heading = lambda level: self._call_editor("apply_heading", level)
|
|
self._tb_bullets = lambda: self._call_editor("toggle_bullets")
|
|
self._tb_numbers = lambda: self._call_editor("toggle_numbers")
|
|
self._tb_checkboxes = lambda: self._call_editor("toggle_checkboxes")
|
|
self._tb_alarm = self._on_alarm_requested
|
|
self._tb_timer = self._on_timer_requested
|
|
self._tb_documents = self._on_documents_requested
|
|
self._tb_font_larger = self._on_font_larger_requested
|
|
self._tb_font_smaller = self._on_font_smaller_requested
|
|
|
|
tb.boldRequested.connect(self._tb_bold)
|
|
tb.italicRequested.connect(self._tb_italic)
|
|
tb.strikeRequested.connect(self._tb_strike)
|
|
tb.codeRequested.connect(self._tb_code)
|
|
tb.headingRequested.connect(self._tb_heading)
|
|
tb.bulletsRequested.connect(self._tb_bullets)
|
|
tb.numbersRequested.connect(self._tb_numbers)
|
|
tb.checkboxesRequested.connect(self._tb_checkboxes)
|
|
tb.alarmRequested.connect(self._tb_alarm)
|
|
tb.timerRequested.connect(self._tb_timer)
|
|
tb.documentsRequested.connect(self._tb_documents)
|
|
tb.insertImageRequested.connect(self._on_insert_image)
|
|
tb.historyRequested.connect(self._open_history)
|
|
tb.fontSizeLargerRequested.connect(self._tb_font_larger)
|
|
tb.fontSizeSmallerRequested.connect(self._tb_font_smaller)
|
|
|
|
self._toolbar_bound = True
|
|
|
|
def _sync_toolbar(self):
|
|
fmt = self.editor.currentCharFormat()
|
|
c = self.editor.textCursor()
|
|
|
|
# Block signals so setChecked() doesn't re-trigger actions
|
|
QSignalBlocker(self.toolBar.actBold)
|
|
QSignalBlocker(self.toolBar.actItalic)
|
|
QSignalBlocker(self.toolBar.actStrike)
|
|
|
|
self.toolBar.actBold.setChecked(fmt.fontWeight() == QFont.Weight.Bold)
|
|
self.toolBar.actItalic.setChecked(fmt.fontItalic())
|
|
self.toolBar.actStrike.setChecked(fmt.fontStrikeOut())
|
|
|
|
# Headings: decide which to check by current point size
|
|
def _approx(a, b, eps=0.5): # small float tolerance
|
|
return abs(float(a) - float(b)) <= eps
|
|
|
|
cur_size = fmt.fontPointSize() or self.editor.font().pointSizeF()
|
|
|
|
bH1 = _approx(cur_size, 24)
|
|
bH2 = _approx(cur_size, 18)
|
|
bH3 = _approx(cur_size, 14)
|
|
|
|
QSignalBlocker(self.toolBar.actH1)
|
|
QSignalBlocker(self.toolBar.actH2)
|
|
QSignalBlocker(self.toolBar.actH3)
|
|
QSignalBlocker(self.toolBar.actNormal)
|
|
|
|
self.toolBar.actH1.setChecked(bH1)
|
|
self.toolBar.actH2.setChecked(bH2)
|
|
self.toolBar.actH3.setChecked(bH3)
|
|
self.toolBar.actNormal.setChecked(not (bH1 or bH2 or bH3))
|
|
|
|
# Lists
|
|
lst = c.currentList()
|
|
bullets_on = lst and lst.format().style() == QTextListFormat.Style.ListDisc
|
|
numbers_on = lst and lst.format().style() == QTextListFormat.Style.ListDecimal
|
|
QSignalBlocker(self.toolBar.actBullets)
|
|
QSignalBlocker(self.toolBar.actNumbers)
|
|
self.toolBar.actBullets.setChecked(bool(bullets_on))
|
|
self.toolBar.actNumbers.setChecked(bool(numbers_on))
|
|
|
|
def _change_font_size(self, delta: int) -> None:
|
|
"""Change font size for all editor tabs and save the setting."""
|
|
old_size = self.cfg.font_size
|
|
new_size = old_size + delta
|
|
|
|
self.cfg.font_size = new_size
|
|
# save size to settings
|
|
cfg = load_db_config()
|
|
cfg.font_size = self.cfg.font_size
|
|
save_db_config(cfg)
|
|
|
|
# Apply font size change to all open editors
|
|
self._apply_font_size_to_all_tabs(new_size)
|
|
|
|
def _apply_font_size_to_all_tabs(self, size: int) -> None:
|
|
for i in range(self.tab_widget.count()):
|
|
ed = self.tab_widget.widget(i)
|
|
if not isinstance(ed, MarkdownEditor):
|
|
continue
|
|
ed.qfont.setPointSize(size)
|
|
ed.setFont(ed.qfont)
|
|
|
|
def _on_font_larger_requested(self) -> None:
|
|
self._change_font_size(+1)
|
|
|
|
def _on_font_smaller_requested(self) -> None:
|
|
self._change_font_size(-1)
|
|
|
|
# ----------- Alarms handler ------------#
|
|
def _on_alarm_requested(self):
|
|
self.upcoming_reminders._add_reminder()
|
|
|
|
def _on_timer_requested(self):
|
|
"""Toggle the embedded Pomodoro timer for the current line."""
|
|
action = self.toolBar.actTimer
|
|
|
|
# Turned on -> start a new timer for the current line
|
|
if action.isChecked():
|
|
editor = getattr(self, "editor", None)
|
|
if editor is None:
|
|
# No editor; immediately reset the toggle
|
|
action.setChecked(False)
|
|
return
|
|
|
|
# Get the current line text
|
|
line_text = editor.get_current_line_task_text()
|
|
if not line_text:
|
|
line_text = strings._("pomodoro_time_log_default_text")
|
|
|
|
# Get current date
|
|
date_iso = self.editor.current_date.toString("yyyy-MM-dd")
|
|
|
|
# Start the timer embedded in the sidebar
|
|
self.pomodoro_manager.start_timer_for_line(line_text, date_iso)
|
|
else:
|
|
# Turned off -> cancel any running timer and remove the widget
|
|
self.pomodoro_manager.cancel_timer()
|
|
|
|
def _send_reminder_webhook(self, text: str):
|
|
if self.cfg.reminders and self.cfg.reminders_webhook_url:
|
|
reminder_webhook = ReminderWebHook(text)
|
|
reminder_webhook._send()
|
|
|
|
def _show_flashing_reminder(self, text: str):
|
|
"""
|
|
Show a small flashing dialog and request attention from the OS.
|
|
Called by reminder timers.
|
|
"""
|
|
# Ask OS to flash / bounce our app in the dock/taskbar
|
|
QApplication.alert(self, 0)
|
|
|
|
# Try to bring the window to the front
|
|
self.showNormal()
|
|
self.raise_()
|
|
self.activateWindow()
|
|
|
|
# Simple dialog with a flashing background to reinforce the alert
|
|
dlg = QDialog(self)
|
|
dlg.setWindowTitle(strings._("reminder"))
|
|
dlg.setModal(True)
|
|
dlg.setMinimumWidth(400)
|
|
|
|
layout = QVBoxLayout(dlg)
|
|
label = QLabel(text)
|
|
label.setWordWrap(True)
|
|
layout.addWidget(label)
|
|
|
|
btn = QPushButton(strings._("dismiss"))
|
|
btn.clicked.connect(dlg.accept)
|
|
layout.addWidget(btn)
|
|
|
|
flash_timer = QTimer(dlg)
|
|
flash_state = {"on": False}
|
|
|
|
def toggle():
|
|
flash_state["on"] = not flash_state["on"]
|
|
if flash_state["on"]:
|
|
dlg.setStyleSheet("background-color: #3B3B3B;")
|
|
else:
|
|
dlg.setStyleSheet("")
|
|
|
|
flash_timer.timeout.connect(toggle)
|
|
flash_timer.start(500) # ms
|
|
|
|
dlg.exec()
|
|
|
|
flash_timer.stop()
|
|
|
|
def _clear_reminder_timers(self):
|
|
"""Stop and delete any existing reminder timers."""
|
|
for t in self._reminder_timers:
|
|
try:
|
|
t.stop()
|
|
t.deleteLater()
|
|
except Exception:
|
|
pass
|
|
self._reminder_timers = []
|
|
|
|
def _rebuild_reminders_for_today(self):
|
|
"""
|
|
Scan the markdown for today's date and create QTimers
|
|
only for alarms on the *current day* (system date).
|
|
"""
|
|
# We only ever set timers for the real current date
|
|
today = QDate.currentDate()
|
|
today_iso = today.toString("yyyy-MM-dd")
|
|
|
|
# Clear any previously scheduled "today" reminders
|
|
self._clear_reminder_timers()
|
|
|
|
# Prefer live editor content if it is showing today's page
|
|
text = ""
|
|
if (
|
|
hasattr(self, "editor")
|
|
and hasattr(self.editor, "current_date")
|
|
and self.editor.current_date == today
|
|
):
|
|
text = self.editor.to_markdown()
|
|
else:
|
|
# Fallback to DB: still only today's date
|
|
text = self.db.get_entry(today_iso) if hasattr(self, "db") else ""
|
|
|
|
if not text:
|
|
return
|
|
|
|
now = QDateTime.currentDateTime()
|
|
|
|
for line in text.splitlines():
|
|
# Look for "⏰ HH:MM" anywhere in the line
|
|
m = re.search(r"⏰\s*(\d{1,2}):(\d{2})", line)
|
|
if not m:
|
|
continue
|
|
|
|
hour = int(m.group(1))
|
|
minute = int(m.group(2))
|
|
|
|
t = QTime(hour, minute)
|
|
if not t.isValid():
|
|
continue
|
|
|
|
target = QDateTime(today, t)
|
|
|
|
# Skip alarms that are already in the past
|
|
if target <= now:
|
|
continue
|
|
|
|
# The reminder text is the part before the symbol
|
|
reminder_text = line.split("⏰", 1)[0].strip()
|
|
if not reminder_text:
|
|
reminder_text = strings._("reminder_no_text_fallback")
|
|
|
|
msecs = now.msecsTo(target)
|
|
timer = QTimer(self)
|
|
timer.setSingleShot(True)
|
|
timer.timeout.connect(
|
|
lambda txt=reminder_text: self._show_flashing_reminder(txt)
|
|
)
|
|
timer.start(msecs)
|
|
self._reminder_timers.append(timer)
|
|
|
|
# ----------- Documents handler ------------#
|
|
def _on_documents_requested(self):
|
|
documents_dlg = DocumentsDialog(self.db, self)
|
|
documents_dlg.exec()
|
|
# Refresh recent documents after any changes
|
|
if hasattr(self, "todays_documents"):
|
|
self.todays_documents.reload()
|
|
|
|
# ----------- History handler ------------#
|
|
def _open_history(self):
|
|
if hasattr(self.editor, "current_date"):
|
|
date_iso = self.editor.current_date.toString("yyyy-MM-dd")
|
|
else:
|
|
date_iso = self._current_date_iso()
|
|
|
|
dlg = HistoryDialog(self.db, date_iso, self, themes=self.themes)
|
|
if dlg.exec() == QDialog.Accepted:
|
|
# refresh editor + calendar (head pointer may have changed)
|
|
self._load_selected_date(date_iso)
|
|
self._refresh_calendar_marks()
|
|
|
|
# ----------- Image insert handler ------------#
|
|
def _on_insert_image(self):
|
|
# Let the user pick one or many images
|
|
paths, _ = QFileDialog.getOpenFileNames(
|
|
self,
|
|
strings._("insert_images"),
|
|
"",
|
|
strings._("images") + "(*.png *.jpg *.jpeg *.bmp *.gif *.webp)",
|
|
)
|
|
if not paths:
|
|
return
|
|
# Insert each image
|
|
for path_str in paths:
|
|
self.editor.insert_image_from_path(Path(path_str))
|
|
|
|
# ----------- Tags handler ----------------#
|
|
def _update_tag_views_for_date(self, date_iso: str):
|
|
if hasattr(self, "tags"):
|
|
self.tags.set_current_date(date_iso)
|
|
if hasattr(self, "time_log"):
|
|
self.time_log.set_current_date(date_iso)
|
|
if hasattr(self, "todays_documents"):
|
|
self.todays_documents.set_current_date(date_iso)
|
|
|
|
def _on_tag_added(self):
|
|
"""Called when a tag is added - trigger autosave for current page"""
|
|
# Use QTimer to defer the save slightly, avoiding re-entrancy issues
|
|
from PySide6.QtCore import QTimer
|
|
|
|
QTimer.singleShot(0, self._do_tag_save)
|
|
|
|
def _do_tag_save(self):
|
|
"""Actually perform the save after tag is added"""
|
|
if hasattr(self, "editor") and hasattr(self.editor, "current_date"):
|
|
date_iso = self.editor.current_date.toString("yyyy-MM-dd")
|
|
|
|
# Get current editor content
|
|
text = self.editor.to_markdown()
|
|
|
|
# Save the content (or blank if page is empty)
|
|
# This ensures the page shows up in tag browser
|
|
self.db.save_new_version(date_iso, text, note="Tag added")
|
|
self._dirty = False
|
|
self._refresh_calendar_marks()
|
|
from datetime import datetime as _dt
|
|
|
|
self.statusBar().showMessage(
|
|
strings._("saved") + f" {date_iso}: {_dt.now().strftime('%H:%M:%S')}",
|
|
2000,
|
|
)
|
|
|
|
def _on_tag_activated(self, tag_name_or_date: str):
|
|
# If it's a date (YYYY-MM-DD format), load it
|
|
if len(tag_name_or_date) == 10 and tag_name_or_date.count("-") == 2:
|
|
self._load_selected_date(tag_name_or_date)
|
|
else:
|
|
# It's a tag name, open the tag browser
|
|
from .tag_browser import TagBrowserDialog
|
|
|
|
dlg = TagBrowserDialog(self.db, self, focus_tag=tag_name_or_date)
|
|
dlg.openDateRequested.connect(self._load_selected_date)
|
|
dlg.tagsModified.connect(self._refresh_current_page_tags)
|
|
dlg.exec()
|
|
|
|
def _refresh_current_page_tags(self):
|
|
"""Refresh the tag chips for the current page (after tag browser changes)"""
|
|
if hasattr(self, "tags") and hasattr(self.editor, "current_date"):
|
|
date_iso = self.editor.current_date.toString("yyyy-MM-dd")
|
|
self.tags.set_current_date(date_iso)
|
|
if self.tags.toggle_btn.isChecked():
|
|
self.tags._reload_tags()
|
|
|
|
# ----------- Settings handler ------------#
|
|
def _open_settings(self):
|
|
dlg = SettingsDialog(self.cfg, self.db, self)
|
|
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)
|
|
self.cfg.theme = getattr(new_cfg, "theme", self.cfg.theme)
|
|
self.cfg.move_todos = getattr(new_cfg, "move_todos", self.cfg.move_todos)
|
|
self.cfg.move_todos_include_weekends = getattr(
|
|
new_cfg,
|
|
"move_todos_include_weekends",
|
|
getattr(self.cfg, "move_todos_include_weekends", False),
|
|
)
|
|
self.cfg.tags = getattr(new_cfg, "tags", self.cfg.tags)
|
|
self.cfg.time_log = getattr(new_cfg, "time_log", self.cfg.time_log)
|
|
self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders)
|
|
self.cfg.reminders_webhook_url = getattr(
|
|
new_cfg, "reminders_webhook_url", self.cfg.reminders_webhook_url
|
|
)
|
|
self.cfg.reminders_webhook_secret = getattr(
|
|
new_cfg, "reminders_webhook_secret", self.cfg.reminders_webhook_secret
|
|
)
|
|
self.cfg.documents = getattr(new_cfg, "documents", self.cfg.documents)
|
|
self.cfg.invoicing = getattr(new_cfg, "invoicing", self.cfg.invoicing)
|
|
self.cfg.locale = getattr(new_cfg, "locale", self.cfg.locale)
|
|
self.cfg.font_size = getattr(new_cfg, "font_size", self.cfg.font_size)
|
|
|
|
# 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)
|
|
# Apply font size to all tabs
|
|
self._apply_font_size_to_all_tabs(self.cfg.font_size)
|
|
|
|
# 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,
|
|
strings._("reopen_failed"),
|
|
strings._("could_not_unlock_database_at_new_path"),
|
|
)
|
|
return
|
|
self._load_selected_date()
|
|
self._refresh_calendar_marks()
|
|
|
|
# Show or hide the tags and time_log features depending on what the settings are now.
|
|
self.tags.hide() if not self.cfg.tags else self.tags.show()
|
|
if not self.cfg.time_log:
|
|
self.time_log.hide()
|
|
self.toolBar.actTimer.setVisible(False)
|
|
else:
|
|
self.time_log.show()
|
|
self.toolBar.actTimer.setVisible(True)
|
|
if not self.cfg.reminders:
|
|
self.upcoming_reminders.hide()
|
|
self.toolBar.actAlarm.setVisible(False)
|
|
else:
|
|
self.upcoming_reminders.show()
|
|
self.toolBar.actAlarm.setVisible(True)
|
|
if not self.cfg.documents:
|
|
self.todays_documents.hide()
|
|
self.toolBar.actDocuments.setVisible(False)
|
|
else:
|
|
self.todays_documents.show()
|
|
self.toolBar.actDocuments.setVisible(True)
|
|
|
|
# ------------ Statistics handler --------------- #
|
|
|
|
def _open_statistics(self):
|
|
if not getattr(self, "db", None) or self.db.conn is None:
|
|
return
|
|
|
|
dlg = StatisticsDialog(self.db, self)
|
|
|
|
if hasattr(dlg, "_heatmap"):
|
|
|
|
def on_date_clicked(d: datetime.date):
|
|
qd = QDate(d.year, d.month, d.day)
|
|
self._open_date_in_tab(qd)
|
|
|
|
dlg._heatmap.date_clicked.connect(on_date_clicked)
|
|
dlg.exec()
|
|
|
|
# ------------ Window positioning --------------- #
|
|
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())
|
|
|
|
# ----------------- Export handler ----------------- #
|
|
@Slot()
|
|
def _export(self):
|
|
warning_title = strings._("unencrypted_export")
|
|
warning_message = strings._("unencrypted_export_warning")
|
|
dlg = QMessageBox()
|
|
dlg.setWindowTitle(warning_title)
|
|
dlg.setText(warning_message)
|
|
dlg.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
|
|
dlg.setIcon(QMessageBox.Warning)
|
|
dlg.show()
|
|
dlg.adjustSize()
|
|
if dlg.exec() != QMessageBox.Yes:
|
|
return False
|
|
|
|
filters = (
|
|
"JSON (*.json);;"
|
|
"CSV (*.csv);;"
|
|
"HTML (*.html);;"
|
|
"Markdown (*.md);;"
|
|
"SQL (*.sql);;"
|
|
)
|
|
|
|
start_dir = os.path.join(os.path.expanduser("~"), "Documents")
|
|
filename, selected_filter = QFileDialog.getSaveFileName(
|
|
self, strings._("export_entries"), start_dir, filters
|
|
)
|
|
if not filename:
|
|
return # user cancelled
|
|
|
|
default_ext = {
|
|
"JSON (*.json)": ".json",
|
|
"CSV (*.csv)": ".csv",
|
|
"HTML (*.html)": ".html",
|
|
"Markdown (*.md)": ".md",
|
|
"SQL (*.sql)": ".sql",
|
|
}.get(selected_filter, ".md")
|
|
|
|
if not Path(filename).suffix:
|
|
filename += default_ext
|
|
|
|
try:
|
|
entries = self.db.get_all_entries()
|
|
if selected_filter.startswith("JSON"):
|
|
self.db.export_json(entries, filename)
|
|
elif selected_filter.startswith("CSV"):
|
|
self.db.export_csv(entries, filename)
|
|
elif selected_filter.startswith("HTML"):
|
|
self.db.export_html(entries, filename)
|
|
elif selected_filter.startswith("Markdown"):
|
|
self.db.export_markdown(entries, filename)
|
|
elif selected_filter.startswith("SQL"):
|
|
self.db.export_sql(filename)
|
|
else:
|
|
raise ValueError(strings._("unrecognised_extension"))
|
|
|
|
QMessageBox.information(
|
|
self,
|
|
strings._("export_complete"),
|
|
strings._("saved_to") + f" {filename}",
|
|
)
|
|
except Exception as e:
|
|
QMessageBox.critical(self, strings._("export_failed"), str(e))
|
|
|
|
# ----------------- Backup handler ----------------- #
|
|
@Slot()
|
|
def _backup(self):
|
|
filters = "SQLCipher (*.db);;"
|
|
|
|
now = datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
|
|
start_dir = os.path.join(
|
|
os.path.expanduser("~"), "Documents", f"bouquin_backup_{now}.db"
|
|
)
|
|
filename, selected_filter = QFileDialog.getSaveFileName(
|
|
self, strings._("backup_encrypted_notebook"), start_dir, filters
|
|
)
|
|
if not filename:
|
|
return # user cancelled
|
|
|
|
default_ext = {
|
|
"SQLCipher (*.db)": ".db",
|
|
}.get(selected_filter, ".db")
|
|
|
|
if not Path(filename).suffix:
|
|
filename += default_ext
|
|
|
|
try:
|
|
if selected_filter.startswith("SQL"):
|
|
self.db.export_sqlcipher(filename)
|
|
QMessageBox.information(
|
|
self,
|
|
strings._("backup_complete"),
|
|
strings._("saved_to") + f" {filename}",
|
|
)
|
|
except Exception as e:
|
|
QMessageBox.critical(self, strings._("backup_failed"), str(e))
|
|
|
|
# ----------------- Help handlers ----------------- #
|
|
|
|
def _open_docs(self):
|
|
url_str = "https://git.mig5.net/mig5/bouquin/wiki/Help"
|
|
url = QUrl.fromUserInput(url_str)
|
|
if not QDesktopServices.openUrl(url):
|
|
QMessageBox.warning(
|
|
self,
|
|
strings._("documentation"),
|
|
strings._("couldnt_open") + url.toDisplayString(),
|
|
)
|
|
|
|
def _open_bugs(self):
|
|
dlg = BugReportDialog(self)
|
|
dlg.exec()
|
|
|
|
def _open_version(self):
|
|
self.version_checker.show_version_dialog()
|
|
|
|
# ----------------- 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 currently locked, unlock when user disables the timer:
|
|
if getattr(self, "_locked", False):
|
|
self._locked = False
|
|
if hasattr(self, "_lock_overlay"):
|
|
self._lock_overlay.hide()
|
|
else:
|
|
self._idle_timer.setInterval(minutes * 60 * 1000)
|
|
if not getattr(self, "_locked", False):
|
|
self._idle_timer.start()
|
|
|
|
def eventFilter(self, obj, event):
|
|
# Catch right-clicks on calendar BEFORE selectionChanged can fire
|
|
if obj == self.calendar and event.type() == QEvent.MouseButtonPress:
|
|
# QMouseEvent in PySide6
|
|
if event.button() == Qt.RightButton:
|
|
self._showing_context_menu = True
|
|
|
|
if event.type() == QEvent.KeyPress and not self._locked:
|
|
self._idle_timer.start()
|
|
|
|
if event.type() in (QEvent.ApplicationActivate, QEvent.WindowActivate):
|
|
QTimer.singleShot(0, self._focus_editor_now)
|
|
|
|
return super().eventFilter(obj, event)
|
|
|
|
def _enter_lock(self):
|
|
"""
|
|
Trigger the lock overlay and disable widgets
|
|
"""
|
|
if self._locked:
|
|
return
|
|
self._locked = True
|
|
if self.menuBar():
|
|
self.menuBar().setEnabled(False)
|
|
if self.statusBar():
|
|
self.statusBar().setEnabled(False)
|
|
self.statusBar().hide()
|
|
tb = getattr(self, "toolBar", None)
|
|
if tb:
|
|
tb.setEnabled(False)
|
|
tb.hide()
|
|
self._lock_overlay.show()
|
|
self._lock_overlay.raise_()
|
|
lock_msg = strings._("lock_overlay_locked")
|
|
self.setWindowTitle(f"{APP_NAME} ({lock_msg})")
|
|
|
|
@Slot()
|
|
def _on_unlock_clicked(self):
|
|
"""
|
|
Prompt for key to unlock screen
|
|
If successful, re-enable widgets
|
|
"""
|
|
try:
|
|
ok = self._prompt_for_key_until_valid(first_time=False)
|
|
except Exception as e:
|
|
QMessageBox.critical(self, strings._("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)
|
|
self.statusBar().show()
|
|
tb = getattr(self, "toolBar", None)
|
|
if tb:
|
|
tb.setEnabled(True)
|
|
tb.show()
|
|
self._idle_timer.start()
|
|
QTimer.singleShot(0, self._focus_editor_now)
|
|
self.setWindowTitle(APP_NAME)
|
|
|
|
# ----------------- Close handlers ----------------- #
|
|
def closeEvent(self, event):
|
|
# Persist geometry if settings exist (window might be half-initialized).
|
|
if getattr(self, "settings", None) is not None:
|
|
try:
|
|
self.settings.setValue("main/geometry", self.saveGeometry())
|
|
self.settings.setValue("main/windowState", self.saveState())
|
|
self.settings.setValue("main/maximized", self.isMaximized())
|
|
except Exception:
|
|
pass
|
|
|
|
# Stop timers if present to avoid late autosaves firing during teardown.
|
|
for _t in ("_autosave_timer", "_idle_timer"):
|
|
t = getattr(self, _t, None)
|
|
if t:
|
|
t.stop()
|
|
|
|
# Save content from tabs if the database is still connected
|
|
db = getattr(self, "db", None)
|
|
conn = getattr(db, "conn", None)
|
|
tw = getattr(self, "tab_widget", None)
|
|
if db is not None and conn is not None and tw is not None:
|
|
try:
|
|
for i in range(tw.count()):
|
|
editor = tw.widget(i)
|
|
if editor is not None:
|
|
self._save_editor_content(editor)
|
|
except Exception:
|
|
# Don't let teardown crash if one tab fails to save.
|
|
pass
|
|
try:
|
|
db.close()
|
|
except Exception:
|
|
pass
|
|
|
|
super().closeEvent(event)
|
|
|
|
# ----------------- Below logic helps focus the editor ----------------- #
|
|
|
|
def _focus_editor_now(self):
|
|
"""Give focus to the editor and ensure the caret is visible."""
|
|
if getattr(self, "_locked", False):
|
|
return
|
|
if not self.isActiveWindow():
|
|
return
|
|
# Belt-and-suspenders: do it now and once more on the next tick
|
|
self.editor.setFocus(Qt.ActiveWindowFocusReason)
|
|
self.editor.ensureCursorVisible()
|
|
QTimer.singleShot(
|
|
0,
|
|
lambda: (
|
|
(
|
|
self.editor.setFocus(Qt.ActiveWindowFocusReason)
|
|
if self.editor
|
|
else None
|
|
),
|
|
self.editor.ensureCursorVisible() if self.editor else None,
|
|
),
|
|
)
|
|
|
|
def _on_app_state_changed(self, state):
|
|
# Called on macOS/Wayland/Windows when the whole app re-activates
|
|
if state == Qt.ApplicationActive and self.isActiveWindow():
|
|
QTimer.singleShot(0, self._focus_editor_now)
|
|
|
|
def changeEvent(self, ev):
|
|
# Called on some platforms when the window's activation state flips
|
|
super().changeEvent(ev)
|
|
if ev.type() == QEvent.ActivationChange and self.isActiveWindow():
|
|
QTimer.singleShot(0, self._focus_editor_now)
|