bouquin/bouquin/main_window.py
Miguel Jacq 55b78833ac
Some checks failed
CI / test (push) Has been cancelled
Lint / test (push) Has been cancelled
Trivy / test (push) Has been cancelled
Initial work on time logging
2025-11-18 21:51:04 +11:00

1650 lines
60 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 datetime
import importlib.metadata
import os
import sys
import re
from pathlib import Path
from PySide6.QtCore import (
QDate,
QTimer,
Qt,
QSettings,
Slot,
QUrl,
QEvent,
QSignalBlocker,
QDateTime,
QTime,
)
from PySide6.QtGui import (
QAction,
QBrush,
QColor,
QCursor,
QDesktopServices,
QFont,
QGuiApplication,
QKeySequence,
QTextCharFormat,
QTextCursor,
QTextListFormat,
)
from PySide6.QtWidgets import (
QCalendarWidget,
QDialog,
QFileDialog,
QMainWindow,
QMenu,
QMessageBox,
QSizePolicy,
QSplitter,
QTableView,
QTabWidget,
QVBoxLayout,
QWidget,
QInputDialog,
QLabel,
QPushButton,
QApplication,
)
from .bug_report_dialog import BugReportDialog
from .db import DBManager
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 .save_dialog import SaveDialog
from .search import Search
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
from .settings_dialog import SettingsDialog
from .statistics_dialog import StatisticsDialog
from . import strings
from .tags_widget import PageTagsWidget
from .theme import ThemeManager
from .time_log import TimeLogWidget, TimeReportDialog
from .toolbar import ToolBar
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.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()
# ---- 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)
self.time_log = TimeLogWidget(self.db)
self.tags = PageTagsWidget(self.db)
self.tags.tagActivated.connect(self._on_tag_activated)
self.tags.tagAdded.connect(self._on_tag_added)
# 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.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+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+G")
act_settings.triggered.connect(self._open_settings)
file_menu.addAction(act_settings)
act_export = QAction(strings._("export_accessible_flag"), self)
act_export.setShortcut("Ctrl+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_tags = QAction(strings._("main_window_manage_tags_accessible_flag"), self)
act_tags.setShortcut("Ctrl+T")
act_tags.triggered.connect(self.tags._open_manager)
file_menu.addAction(act_tags)
act_stats = QAction(strings._("main_window_statistics_accessible_flag"), self)
act_stats.setShortcut("Shift+Ctrl+S")
act_stats.triggered.connect(self._open_statistics)
file_menu.addAction(act_stats)
act_time_report = QAction(strings._("time_log_report"), self)
act_time_report.triggered.connect(self._open_time_report)
file_menu.addAction(act_time_report)
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_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+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+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+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_yesterday_todos():
self._load_selected_date()
self._refresh_calendar_marks()
# Restore window position from settings
self.settings = QSettings(APP_ORG, APP_NAME)
self._restore_window_position()
# re-apply all runtime color tweaks when theme changes
self.themes.themeChanged.connect(lambda _t: self._retheme_overrides())
self._apply_calendar_text_colors()
# 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()
@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)
sys.exit(1)
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 (dont 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)
# 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 _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 _load_yesterday_todos(self):
if not self.cfg.move_todos:
return
yesterday_str = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd")
text = self.db.get_entry(yesterday_str)
unchecked_items = []
# Split into lines and find unchecked checkbox items
lines = text.split("\n")
remaining_lines = []
for line in lines:
# Check for unchecked markdown checkboxes: - [ ] or - [☐]
if re.match(r"^\s*-\s*\[\s*\]\s+", line) or re.match(
r"^\s*-\s*\[☐\]\s+", line
):
# Extract the text after the checkbox
item_text = re.sub(r"^\s*-\s*\[[\s☐]\]\s+", "", line)
unchecked_items.append(f"- [ ] {item_text}")
else:
# Keep all other lines
remaining_lines.append(line)
# Save modified content back if we moved items
if unchecked_items:
modified_text = "\n".join(remaining_lines)
self.db.save_new_version(
yesterday_str,
modified_text,
strings._("unchecked_checkbox_items_moved_to_next_day"),
)
# Join unchecked items into markdown format
unchecked_str = "\n".join(unchecked_items) + "\n"
# Load the unchecked items into the current editor
self._load_selected_date(False, unchecked_str)
else:
return False
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 _retheme_overrides(self):
self._apply_calendar_text_colors()
self._apply_search_highlights(getattr(self, "_search_highlighted_dates", set()))
self.calendar.update()
self.editor.viewport().update()
def _apply_calendar_text_colors(self):
pal = self.palette()
txt = pal.windowText().color()
fmt = QTextCharFormat()
fmt.setForeground(txt)
# Use normal text color for weekends
self.calendar.setWeekdayTextFormat(Qt.Saturday, fmt)
self.calendar.setWeekdayTextFormat(Qt.Sunday, fmt)
# --------------- 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
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.insertImageRequested.connect(self._on_insert_image)
tb.historyRequested.connect(self._open_history)
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))
# ----------- Alarms handler ------------#
def _on_alarm_requested(self):
"""Create a one-shot reminder based on the current line in the editor."""
editor = getattr(self, "editor", None)
if editor is None:
return
# Use the current line in the markdown editor as the reminder text
try:
editor.get_current_line_text().strip()
except AttributeError:
c = editor.textCursor()
c.block().text().strip()
# Ask user for a time today in HH:MM format
time_str, ok = QInputDialog.getText(
self,
strings._("set_reminder"),
strings._("set_reminder_prompt") + " (HH:MM)",
)
if not ok or not time_str.strip():
return
try:
hour, minute = map(int, time_str.strip().split(":", 1))
except ValueError:
QMessageBox.warning(
self,
strings._("invalid_time_title"),
strings._("invalid_time_message"),
)
return
t = QTime(hour, minute)
if not t.isValid():
QMessageBox.warning(
self,
strings._("invalid_time_title"),
strings._("invalid_time_message"),
)
return
# Normalise to HH:MM
time_str = f"{t.hour():02d}:{t.minute():02d}"
# Insert / update ⏰ in the editor text
if hasattr(editor, "insert_alarm_marker"):
editor.insert_alarm_marker(time_str)
# Rebuild timers, but only if this page is for "today"
self._rebuild_reminders_for_today()
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)
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)
# ----------- 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)
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)
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.locale = getattr(new_cfg, "locale", self.cfg.locale)
# 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,
strings._("reopen_failed"),
strings._("could_not_unlock_database_at_new_path"),
)
return
self._load_selected_date()
self._refresh_calendar_marks()
# ------------ 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()
# ------------ Timesheet report handler --------------- #
def _open_time_report(self):
dlg = TimeReportDialog(self.db, self)
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):
version = importlib.metadata.version("bouquin")
version_formatted = f"{APP_NAME} {version}"
QMessageBox.information(self, strings._("version"), version_formatted)
# ----------------- 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_()
@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)
# ----------------- 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)