bouquin 0.2.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {bouquin-0.2.0 → bouquin-0.2.1}/PKG-INFO +4 -5
- {bouquin-0.2.0 → bouquin-0.2.1}/README.md +3 -4
- {bouquin-0.2.0 → bouquin-0.2.1}/bouquin/find_bar.py +30 -7
- {bouquin-0.2.0 → bouquin-0.2.1}/bouquin/main_window.py +370 -45
- {bouquin-0.2.0 → bouquin-0.2.1}/bouquin/markdown_editor.py +63 -45
- {bouquin-0.2.0 → bouquin-0.2.1}/pyproject.toml +1 -1
- {bouquin-0.2.0 → bouquin-0.2.1}/LICENSE +0 -0
- {bouquin-0.2.0 → bouquin-0.2.1}/bouquin/__init__.py +0 -0
- {bouquin-0.2.0 → bouquin-0.2.1}/bouquin/__main__.py +0 -0
- {bouquin-0.2.0 → bouquin-0.2.1}/bouquin/db.py +0 -0
- {bouquin-0.2.0 → bouquin-0.2.1}/bouquin/history_dialog.py +0 -0
- {bouquin-0.2.0 → bouquin-0.2.1}/bouquin/key_prompt.py +0 -0
- {bouquin-0.2.0 → bouquin-0.2.1}/bouquin/lock_overlay.py +0 -0
- {bouquin-0.2.0 → bouquin-0.2.1}/bouquin/main.py +0 -0
- {bouquin-0.2.0 → bouquin-0.2.1}/bouquin/save_dialog.py +0 -0
- {bouquin-0.2.0 → bouquin-0.2.1}/bouquin/search.py +0 -0
- {bouquin-0.2.0 → bouquin-0.2.1}/bouquin/settings.py +0 -0
- {bouquin-0.2.0 → bouquin-0.2.1}/bouquin/settings_dialog.py +0 -0
- {bouquin-0.2.0 → bouquin-0.2.1}/bouquin/theme.py +0 -0
- {bouquin-0.2.0 → bouquin-0.2.1}/bouquin/toolbar.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: bouquin
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
|
|
5
5
|
Home-page: https://git.mig5.net/mig5/bouquin
|
|
6
6
|
License: GPL-3.0-or-later
|
|
@@ -35,9 +35,7 @@ There is deliberately no network connectivity or syncing intended.
|
|
|
35
35
|
|
|
36
36
|
## Screenshot
|
|
37
37
|
|
|
38
|
-

|
|
38
|
+

|
|
41
39
|
|
|
42
40
|
## Features
|
|
43
41
|
|
|
@@ -46,8 +44,9 @@ There is deliberately no network connectivity or syncing intended.
|
|
|
46
44
|
* Every 'page' is linked to the calendar day
|
|
47
45
|
* All changes are version controlled, with ability to view/diff versions and revert
|
|
48
46
|
* Text is Markdown with basic styling
|
|
47
|
+
* Tabs are supported - right-click on a date from the calendar to open it in a new tab.
|
|
49
48
|
* Images are supported
|
|
50
|
-
* Search
|
|
49
|
+
* Search all pages, or find text on page (Ctrl+F)
|
|
51
50
|
* Automatic periodic saving (or explicitly save)
|
|
52
51
|
* Transparent integrity checking of the database when it opens
|
|
53
52
|
* Automatic locking of the app after a period of inactivity (default 15 min)
|
|
@@ -15,9 +15,7 @@ There is deliberately no network connectivity or syncing intended.
|
|
|
15
15
|
|
|
16
16
|
## Screenshot
|
|
17
17
|
|
|
18
|
-

|
|
18
|
+

|
|
21
19
|
|
|
22
20
|
## Features
|
|
23
21
|
|
|
@@ -26,8 +24,9 @@ There is deliberately no network connectivity or syncing intended.
|
|
|
26
24
|
* Every 'page' is linked to the calendar day
|
|
27
25
|
* All changes are version controlled, with ability to view/diff versions and revert
|
|
28
26
|
* Text is Markdown with basic styling
|
|
27
|
+
* Tabs are supported - right-click on a date from the calendar to open it in a new tab.
|
|
29
28
|
* Images are supported
|
|
30
|
-
* Search
|
|
29
|
+
* Search all pages, or find text on page (Ctrl+F)
|
|
31
30
|
* Automatic periodic saving (or explicitly save)
|
|
32
31
|
* Transparent integrity checking of the database when it opens
|
|
33
32
|
* Automatic locking of the app after a period of inactivity (default 15 min)
|
|
@@ -31,14 +31,19 @@ class FindBar(QWidget):
|
|
|
31
31
|
shortcut_parent: QWidget | None = None,
|
|
32
32
|
parent: QWidget | None = None,
|
|
33
33
|
):
|
|
34
|
+
|
|
34
35
|
super().__init__(parent)
|
|
35
|
-
self.editor = editor
|
|
36
36
|
|
|
37
|
-
#
|
|
37
|
+
# store how to get the current editor
|
|
38
|
+
self._editor_getter = editor if callable(editor) else (lambda: editor)
|
|
39
|
+
self.shortcut_parent = shortcut_parent
|
|
40
|
+
|
|
41
|
+
# UI (build ONCE)
|
|
38
42
|
layout = QHBoxLayout(self)
|
|
39
43
|
layout.setContentsMargins(6, 0, 6, 0)
|
|
40
44
|
|
|
41
45
|
layout.addWidget(QLabel("Find:"))
|
|
46
|
+
|
|
42
47
|
self.edit = QLineEdit(self)
|
|
43
48
|
self.edit.setPlaceholderText("Type to search…")
|
|
44
49
|
layout.addWidget(self.edit)
|
|
@@ -56,11 +61,15 @@ class FindBar(QWidget):
|
|
|
56
61
|
|
|
57
62
|
self.setVisible(False)
|
|
58
63
|
|
|
59
|
-
# Shortcut
|
|
60
|
-
sp =
|
|
61
|
-
|
|
64
|
+
# Shortcut (press Esc to hide bar)
|
|
65
|
+
sp = (
|
|
66
|
+
self.shortcut_parent
|
|
67
|
+
if self.shortcut_parent is not None
|
|
68
|
+
else (self.parent() or self)
|
|
69
|
+
)
|
|
70
|
+
QShortcut(Qt.Key_Escape, sp, activated=self._maybe_hide)
|
|
62
71
|
|
|
63
|
-
# Signals
|
|
72
|
+
# Signals (connect ONCE)
|
|
64
73
|
self.edit.returnPressed.connect(self.find_next)
|
|
65
74
|
self.edit.textChanged.connect(self._update_highlight)
|
|
66
75
|
self.case.toggled.connect(self._update_highlight)
|
|
@@ -68,10 +77,17 @@ class FindBar(QWidget):
|
|
|
68
77
|
self.prevBtn.clicked.connect(self.find_prev)
|
|
69
78
|
self.closeBtn.clicked.connect(self.hide_bar)
|
|
70
79
|
|
|
80
|
+
@property
|
|
81
|
+
def editor(self) -> QTextEdit | None:
|
|
82
|
+
"""Get the current editor (no side effects)."""
|
|
83
|
+
return self._editor_getter()
|
|
84
|
+
|
|
71
85
|
# ----- Public API -----
|
|
72
86
|
|
|
73
87
|
def show_bar(self):
|
|
74
88
|
"""Show the bar, seed with current selection if sensible, focus the line edit."""
|
|
89
|
+
if not self.editor:
|
|
90
|
+
return
|
|
75
91
|
tc = self.editor.textCursor()
|
|
76
92
|
sel = tc.selectedText().strip()
|
|
77
93
|
if sel and "\u2029" not in sel: # ignore multi-paragraph selections
|
|
@@ -105,6 +121,8 @@ class FindBar(QWidget):
|
|
|
105
121
|
return flags
|
|
106
122
|
|
|
107
123
|
def find_next(self):
|
|
124
|
+
if not self.editor:
|
|
125
|
+
return
|
|
108
126
|
txt = self.edit.text()
|
|
109
127
|
if not txt:
|
|
110
128
|
return
|
|
@@ -130,6 +148,8 @@ class FindBar(QWidget):
|
|
|
130
148
|
self._update_highlight()
|
|
131
149
|
|
|
132
150
|
def find_prev(self):
|
|
151
|
+
if not self.editor:
|
|
152
|
+
return
|
|
133
153
|
txt = self.edit.text()
|
|
134
154
|
if not txt:
|
|
135
155
|
return
|
|
@@ -155,6 +175,8 @@ class FindBar(QWidget):
|
|
|
155
175
|
self._update_highlight()
|
|
156
176
|
|
|
157
177
|
def _update_highlight(self):
|
|
178
|
+
if not self.editor:
|
|
179
|
+
return
|
|
158
180
|
txt = self.edit.text()
|
|
159
181
|
if not txt:
|
|
160
182
|
self._clear_highlight()
|
|
@@ -183,4 +205,5 @@ class FindBar(QWidget):
|
|
|
183
205
|
self.editor.setExtraSelections(selections)
|
|
184
206
|
|
|
185
207
|
def _clear_highlight(self):
|
|
186
|
-
self.editor
|
|
208
|
+
if self.editor:
|
|
209
|
+
self.editor.setExtraSelections([])
|
|
@@ -36,9 +36,12 @@ from PySide6.QtWidgets import (
|
|
|
36
36
|
QDialog,
|
|
37
37
|
QFileDialog,
|
|
38
38
|
QMainWindow,
|
|
39
|
+
QMenu,
|
|
39
40
|
QMessageBox,
|
|
40
41
|
QSizePolicy,
|
|
41
42
|
QSplitter,
|
|
43
|
+
QTableView,
|
|
44
|
+
QTabWidget,
|
|
42
45
|
QVBoxLayout,
|
|
43
46
|
QWidget,
|
|
44
47
|
)
|
|
@@ -98,34 +101,37 @@ class MainWindow(QMainWindow):
|
|
|
98
101
|
left_layout.addWidget(self.search)
|
|
99
102
|
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
|
|
100
103
|
|
|
101
|
-
#
|
|
102
|
-
self.
|
|
104
|
+
# Create tab widget to hold multiple editors
|
|
105
|
+
self.tab_widget = QTabWidget()
|
|
106
|
+
self.tab_widget.setTabsClosable(True)
|
|
107
|
+
self.tab_widget.tabCloseRequested.connect(self._close_tab)
|
|
108
|
+
self.tab_widget.currentChanged.connect(self._on_tab_changed)
|
|
103
109
|
|
|
104
110
|
# Toolbar for controlling styling
|
|
105
111
|
self.toolBar = ToolBar()
|
|
106
112
|
self.addToolBar(self.toolBar)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
self.toolBar.strikeRequested.connect(self.editor.apply_strikethrough)
|
|
112
|
-
self.toolBar.codeRequested.connect(self.editor.apply_code)
|
|
113
|
-
self.toolBar.headingRequested.connect(self.editor.apply_heading)
|
|
114
|
-
self.toolBar.bulletsRequested.connect(self.editor.toggle_bullets)
|
|
115
|
-
self.toolBar.numbersRequested.connect(self.editor.toggle_numbers)
|
|
116
|
-
self.toolBar.checkboxesRequested.connect(self.editor.toggle_checkboxes)
|
|
117
|
-
# Note: Markdown doesn't natively support alignment, removing alignRequested
|
|
118
|
-
self.toolBar.historyRequested.connect(self._open_history)
|
|
119
|
-
self.toolBar.insertImageRequested.connect(self._on_insert_image)
|
|
120
|
-
|
|
121
|
-
self.editor.currentCharFormatChanged.connect(lambda _f: self._sync_toolbar())
|
|
122
|
-
self.editor.cursorPositionChanged.connect(self._sync_toolbar)
|
|
113
|
+
self._bind_toolbar()
|
|
114
|
+
|
|
115
|
+
# Create the first editor tab
|
|
116
|
+
self._create_new_tab()
|
|
123
117
|
|
|
124
118
|
split = QSplitter()
|
|
125
119
|
split.addWidget(left_panel)
|
|
126
|
-
split.addWidget(self.
|
|
120
|
+
split.addWidget(self.tab_widget)
|
|
127
121
|
split.setStretchFactor(1, 1)
|
|
128
122
|
|
|
123
|
+
# Enable context menu on calendar for opening dates in new tabs
|
|
124
|
+
self.calendar.setContextMenuPolicy(Qt.CustomContextMenu)
|
|
125
|
+
self.calendar.customContextMenuRequested.connect(
|
|
126
|
+
self._show_calendar_context_menu
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Flag to prevent _on_date_changed when showing context menu
|
|
130
|
+
self._showing_context_menu = False
|
|
131
|
+
|
|
132
|
+
# Install event filter to catch right-clicks before selectionChanged fires
|
|
133
|
+
self.calendar.installEventFilter(self)
|
|
134
|
+
|
|
129
135
|
container = QWidget()
|
|
130
136
|
lay = QVBoxLayout(container)
|
|
131
137
|
lay.addWidget(split)
|
|
@@ -162,7 +168,10 @@ class MainWindow(QMainWindow):
|
|
|
162
168
|
# Status bar for feedback
|
|
163
169
|
self.statusBar().showMessage("Ready", 800)
|
|
164
170
|
# Add findBar and add it to the statusBar
|
|
165
|
-
|
|
171
|
+
# FindBar will get the current editor dynamically via a callable
|
|
172
|
+
self.findBar = FindBar(
|
|
173
|
+
lambda: self.current_editor(), shortcut_parent=self, parent=self
|
|
174
|
+
)
|
|
166
175
|
self.statusBar().addPermanentWidget(self.findBar)
|
|
167
176
|
# When the findBar closes, put the caret back in the editor
|
|
168
177
|
self.findBar.closed.connect(self._focus_editor_now)
|
|
@@ -258,7 +267,7 @@ class MainWindow(QMainWindow):
|
|
|
258
267
|
self._save_timer = QTimer(self)
|
|
259
268
|
self._save_timer.setSingleShot(True)
|
|
260
269
|
self._save_timer.timeout.connect(self._save_current)
|
|
261
|
-
|
|
270
|
+
# Note: textChanged will be connected per-editor in _create_new_tab
|
|
262
271
|
|
|
263
272
|
# First load + mark dates in calendar with content
|
|
264
273
|
if not self._load_yesterday_todos():
|
|
@@ -313,6 +322,248 @@ class MainWindow(QMainWindow):
|
|
|
313
322
|
if self._try_connect():
|
|
314
323
|
return True
|
|
315
324
|
|
|
325
|
+
# ----------------- Tab management ----------------- #
|
|
326
|
+
|
|
327
|
+
def _tab_index_for_date(self, date: QDate) -> int:
|
|
328
|
+
"""Return the index of the tab showing `date`, or -1 if none."""
|
|
329
|
+
iso = date.toString("yyyy-MM-dd")
|
|
330
|
+
for i in range(self.tab_widget.count()):
|
|
331
|
+
w = self.tab_widget.widget(i)
|
|
332
|
+
if (
|
|
333
|
+
hasattr(w, "current_date")
|
|
334
|
+
and w.current_date.toString("yyyy-MM-dd") == iso
|
|
335
|
+
):
|
|
336
|
+
return i
|
|
337
|
+
return -1
|
|
338
|
+
|
|
339
|
+
def _open_date_in_tab(self, date: QDate):
|
|
340
|
+
"""Focus existing tab for `date`, or create it if needed. Returns the editor."""
|
|
341
|
+
idx = self._tab_index_for_date(date)
|
|
342
|
+
if idx != -1:
|
|
343
|
+
self.tab_widget.setCurrentIndex(idx)
|
|
344
|
+
# keep calendar selection in sync (don’t trigger load)
|
|
345
|
+
from PySide6.QtCore import QSignalBlocker
|
|
346
|
+
|
|
347
|
+
with QSignalBlocker(self.calendar):
|
|
348
|
+
self.calendar.setSelectedDate(date)
|
|
349
|
+
QTimer.singleShot(0, self._focus_editor_now)
|
|
350
|
+
return self.tab_widget.widget(idx)
|
|
351
|
+
# not open yet -> create
|
|
352
|
+
return self._create_new_tab(date)
|
|
353
|
+
|
|
354
|
+
def _create_new_tab(self, date: QDate | None = None) -> MarkdownEditor:
|
|
355
|
+
if date is None:
|
|
356
|
+
date = self.calendar.selectedDate()
|
|
357
|
+
|
|
358
|
+
# Deduplicate: if already open, just jump there
|
|
359
|
+
existing = self._tab_index_for_date(date)
|
|
360
|
+
if existing != -1:
|
|
361
|
+
self.tab_widget.setCurrentIndex(existing)
|
|
362
|
+
return self.tab_widget.widget(existing)
|
|
363
|
+
|
|
364
|
+
"""Create a new editor tab and return the editor instance."""
|
|
365
|
+
editor = MarkdownEditor(self.themes)
|
|
366
|
+
|
|
367
|
+
# Set up the editor's event connections
|
|
368
|
+
editor.currentCharFormatChanged.connect(lambda _f: self._sync_toolbar())
|
|
369
|
+
editor.cursorPositionChanged.connect(self._sync_toolbar)
|
|
370
|
+
editor.textChanged.connect(self._on_text_changed)
|
|
371
|
+
|
|
372
|
+
# Determine tab title
|
|
373
|
+
if date is None:
|
|
374
|
+
date = self.calendar.selectedDate()
|
|
375
|
+
tab_title = date.toString("yyyy-MM-dd")
|
|
376
|
+
|
|
377
|
+
# Add the tab
|
|
378
|
+
index = self.tab_widget.addTab(editor, tab_title)
|
|
379
|
+
self.tab_widget.setCurrentIndex(index)
|
|
380
|
+
|
|
381
|
+
# Load the date's content
|
|
382
|
+
self._load_date_into_editor(date, editor)
|
|
383
|
+
|
|
384
|
+
# Store the date with the editor so we can save it later
|
|
385
|
+
editor.current_date = date
|
|
386
|
+
|
|
387
|
+
return editor
|
|
388
|
+
|
|
389
|
+
def _close_tab(self, index: int):
|
|
390
|
+
"""Close a tab at the given index."""
|
|
391
|
+
if self.tab_widget.count() <= 1:
|
|
392
|
+
# Don't close the last tab
|
|
393
|
+
return
|
|
394
|
+
|
|
395
|
+
editor = self.tab_widget.widget(index)
|
|
396
|
+
if editor:
|
|
397
|
+
# Save before closing
|
|
398
|
+
self._save_editor_content(editor)
|
|
399
|
+
|
|
400
|
+
self.tab_widget.removeTab(index)
|
|
401
|
+
|
|
402
|
+
def _on_tab_changed(self, index: int):
|
|
403
|
+
"""Handle tab change - reconnect toolbar and sync UI."""
|
|
404
|
+
if index < 0:
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
editor = self.tab_widget.widget(index)
|
|
408
|
+
if editor and hasattr(editor, "current_date"):
|
|
409
|
+
# Update calendar selection to match the tab
|
|
410
|
+
with QSignalBlocker(self.calendar):
|
|
411
|
+
self.calendar.setSelectedDate(editor.current_date)
|
|
412
|
+
|
|
413
|
+
# Reconnect toolbar to new active editor
|
|
414
|
+
self._sync_toolbar()
|
|
415
|
+
|
|
416
|
+
# Focus the editor
|
|
417
|
+
QTimer.singleShot(0, self._focus_editor_now)
|
|
418
|
+
|
|
419
|
+
def _call_editor(self, method_name, *args):
|
|
420
|
+
"""
|
|
421
|
+
Call the relevant method of the MarkdownEditor class on bind
|
|
422
|
+
"""
|
|
423
|
+
ed = self.current_editor()
|
|
424
|
+
if ed is None:
|
|
425
|
+
return
|
|
426
|
+
getattr(ed, method_name)(*args)
|
|
427
|
+
|
|
428
|
+
def _bind_toolbar(self):
|
|
429
|
+
if getattr(self, "_toolbar_bound", False):
|
|
430
|
+
return
|
|
431
|
+
tb = self.toolBar
|
|
432
|
+
|
|
433
|
+
# keep refs so we never create new lambdas (prevents accidental dupes)
|
|
434
|
+
self._tb_bold = lambda: self._call_editor("apply_weight")
|
|
435
|
+
self._tb_italic = lambda: self._call_editor("apply_italic")
|
|
436
|
+
self._tb_strike = lambda: self._call_editor("apply_strikethrough")
|
|
437
|
+
self._tb_code = lambda: self._call_editor("apply_code")
|
|
438
|
+
self._tb_heading = lambda level: self._call_editor("apply_heading", level)
|
|
439
|
+
self._tb_bullets = lambda: self._call_editor("toggle_bullets")
|
|
440
|
+
self._tb_numbers = lambda: self._call_editor("toggle_numbers")
|
|
441
|
+
self._tb_checkboxes = lambda: self._call_editor("toggle_checkboxes")
|
|
442
|
+
|
|
443
|
+
tb.boldRequested.connect(self._tb_bold)
|
|
444
|
+
tb.italicRequested.connect(self._tb_italic)
|
|
445
|
+
tb.strikeRequested.connect(self._tb_strike)
|
|
446
|
+
tb.codeRequested.connect(self._tb_code)
|
|
447
|
+
tb.headingRequested.connect(self._tb_heading)
|
|
448
|
+
tb.bulletsRequested.connect(self._tb_bullets)
|
|
449
|
+
tb.numbersRequested.connect(self._tb_numbers)
|
|
450
|
+
tb.checkboxesRequested.connect(self._tb_checkboxes)
|
|
451
|
+
|
|
452
|
+
# these aren’t editor methods
|
|
453
|
+
tb.historyRequested.connect(self._open_history)
|
|
454
|
+
tb.insertImageRequested.connect(self._on_insert_image)
|
|
455
|
+
|
|
456
|
+
self._toolbar_bound = True
|
|
457
|
+
|
|
458
|
+
def current_editor(self) -> MarkdownEditor | None:
|
|
459
|
+
"""Get the currently active editor."""
|
|
460
|
+
return self.tab_widget.currentWidget()
|
|
461
|
+
|
|
462
|
+
@property
|
|
463
|
+
def editor(self) -> MarkdownEditor | None:
|
|
464
|
+
"""Compatibility property to get current editor (for existing code)."""
|
|
465
|
+
return self.current_editor()
|
|
466
|
+
|
|
467
|
+
def _date_from_calendar_pos(self, pos) -> QDate | None:
|
|
468
|
+
"""Translate a QCalendarWidget local pos to the QDate under the cursor."""
|
|
469
|
+
view: QTableView = self.calendar.findChild(
|
|
470
|
+
QTableView, "qt_calendar_calendarview"
|
|
471
|
+
)
|
|
472
|
+
if view is None:
|
|
473
|
+
return None
|
|
474
|
+
|
|
475
|
+
# Map calendar-local pos -> viewport pos
|
|
476
|
+
vp_pos = view.viewport().mapFrom(self.calendar, pos)
|
|
477
|
+
idx = view.indexAt(vp_pos)
|
|
478
|
+
if not idx.isValid():
|
|
479
|
+
return None
|
|
480
|
+
|
|
481
|
+
model = view.model()
|
|
482
|
+
|
|
483
|
+
# Account for optional headers
|
|
484
|
+
start_col = (
|
|
485
|
+
0
|
|
486
|
+
if self.calendar.verticalHeaderFormat() == QCalendarWidget.NoVerticalHeader
|
|
487
|
+
else 1
|
|
488
|
+
)
|
|
489
|
+
start_row = (
|
|
490
|
+
0
|
|
491
|
+
if self.calendar.horizontalHeaderFormat()
|
|
492
|
+
== QCalendarWidget.NoHorizontalHeader
|
|
493
|
+
else 1
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
# Find index of day 1 (first cell belonging to current month)
|
|
497
|
+
first_index = None
|
|
498
|
+
for r in range(start_row, model.rowCount()):
|
|
499
|
+
for c in range(start_col, model.columnCount()):
|
|
500
|
+
if model.index(r, c).data() == 1:
|
|
501
|
+
first_index = model.index(r, c)
|
|
502
|
+
break
|
|
503
|
+
if first_index:
|
|
504
|
+
break
|
|
505
|
+
if first_index is None:
|
|
506
|
+
return None
|
|
507
|
+
|
|
508
|
+
# Find index of the last day of the current month
|
|
509
|
+
last_day = (
|
|
510
|
+
QDate(self.calendar.yearShown(), self.calendar.monthShown(), 1)
|
|
511
|
+
.addMonths(1)
|
|
512
|
+
.addDays(-1)
|
|
513
|
+
.day()
|
|
514
|
+
)
|
|
515
|
+
last_index = None
|
|
516
|
+
for r in range(model.rowCount() - 1, first_index.row() - 1, -1):
|
|
517
|
+
for c in range(model.columnCount() - 1, start_col - 1, -1):
|
|
518
|
+
if model.index(r, c).data() == last_day:
|
|
519
|
+
last_index = model.index(r, c)
|
|
520
|
+
break
|
|
521
|
+
if last_index:
|
|
522
|
+
break
|
|
523
|
+
if last_index is None:
|
|
524
|
+
return None
|
|
525
|
+
|
|
526
|
+
# Determine if clicked cell belongs to prev/next month or current
|
|
527
|
+
day = int(idx.data())
|
|
528
|
+
year = self.calendar.yearShown()
|
|
529
|
+
month = self.calendar.monthShown()
|
|
530
|
+
|
|
531
|
+
before_first = (idx.row() < first_index.row()) or (
|
|
532
|
+
idx.row() == first_index.row() and idx.column() < first_index.column()
|
|
533
|
+
)
|
|
534
|
+
after_last = (idx.row() > last_index.row()) or (
|
|
535
|
+
idx.row() == last_index.row() and idx.column() > last_index.column()
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
if before_first:
|
|
539
|
+
if month == 1:
|
|
540
|
+
month = 12
|
|
541
|
+
year -= 1
|
|
542
|
+
else:
|
|
543
|
+
month -= 1
|
|
544
|
+
elif after_last:
|
|
545
|
+
if month == 12:
|
|
546
|
+
month = 1
|
|
547
|
+
year += 1
|
|
548
|
+
else:
|
|
549
|
+
month += 1
|
|
550
|
+
|
|
551
|
+
qd = QDate(year, month, day)
|
|
552
|
+
return qd if qd.isValid() else None
|
|
553
|
+
|
|
554
|
+
def _show_calendar_context_menu(self, pos):
|
|
555
|
+
self._showing_context_menu = True # so selectionChanged handler doesn't fire
|
|
556
|
+
clicked_date = self._date_from_calendar_pos(pos)
|
|
557
|
+
|
|
558
|
+
menu = QMenu(self)
|
|
559
|
+
open_in_new_tab_action = menu.addAction("Open in New Tab")
|
|
560
|
+
action = menu.exec_(self.calendar.mapToGlobal(pos))
|
|
561
|
+
|
|
562
|
+
self._showing_context_menu = False
|
|
563
|
+
|
|
564
|
+
if action == open_in_new_tab_action and clicked_date and clicked_date.isValid():
|
|
565
|
+
self._open_date_in_tab(clicked_date)
|
|
566
|
+
|
|
316
567
|
def _retheme_overrides(self):
|
|
317
568
|
if hasattr(self, "_lock_overlay"):
|
|
318
569
|
self._lock_overlay._apply_overlay_style()
|
|
@@ -494,8 +745,28 @@ class MainWindow(QMainWindow):
|
|
|
494
745
|
return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
|
|
495
746
|
|
|
496
747
|
def _load_selected_date(self, date_iso=False, extra_data=False):
|
|
748
|
+
"""Load a date into the current editor (backward compatibility)."""
|
|
749
|
+
editor = self.current_editor()
|
|
750
|
+
if not editor:
|
|
751
|
+
return
|
|
752
|
+
|
|
497
753
|
if not date_iso:
|
|
498
754
|
date_iso = self._current_date_iso()
|
|
755
|
+
|
|
756
|
+
qd = QDate.fromString(date_iso, "yyyy-MM-dd")
|
|
757
|
+
self._load_date_into_editor(qd, editor, extra_data)
|
|
758
|
+
editor.current_date = qd
|
|
759
|
+
|
|
760
|
+
# Update tab title
|
|
761
|
+
current_index = self.tab_widget.currentIndex()
|
|
762
|
+
if current_index >= 0:
|
|
763
|
+
self.tab_widget.setTabText(current_index, date_iso)
|
|
764
|
+
|
|
765
|
+
def _load_date_into_editor(
|
|
766
|
+
self, date: QDate, editor: MarkdownEditor, extra_data=False
|
|
767
|
+
):
|
|
768
|
+
"""Load a specific date's content into a given editor."""
|
|
769
|
+
date_iso = date.toString("yyyy-MM-dd")
|
|
499
770
|
try:
|
|
500
771
|
text = self.db.get_entry(date_iso)
|
|
501
772
|
if extra_data:
|
|
@@ -504,21 +775,26 @@ class MainWindow(QMainWindow):
|
|
|
504
775
|
text += "\n"
|
|
505
776
|
text += extra_data
|
|
506
777
|
# Force a save now so we don't lose it.
|
|
507
|
-
self._set_editor_markdown_preserve_view(text)
|
|
778
|
+
self._set_editor_markdown_preserve_view(text, editor)
|
|
508
779
|
self._dirty = True
|
|
509
780
|
self._save_date(date_iso, True)
|
|
510
|
-
|
|
511
781
|
except Exception as e:
|
|
512
782
|
QMessageBox.critical(self, "Read Error", str(e))
|
|
513
783
|
return
|
|
514
784
|
|
|
515
|
-
self._set_editor_markdown_preserve_view(text)
|
|
516
|
-
|
|
785
|
+
self._set_editor_markdown_preserve_view(text, editor)
|
|
517
786
|
self._dirty = False
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
787
|
+
|
|
788
|
+
def _save_editor_content(self, editor: MarkdownEditor):
|
|
789
|
+
"""Save a specific editor's content to its associated date."""
|
|
790
|
+
if not hasattr(editor, "current_date"):
|
|
791
|
+
return
|
|
792
|
+
date_iso = editor.current_date.toString("yyyy-MM-dd")
|
|
793
|
+
try:
|
|
794
|
+
md = editor.to_markdown()
|
|
795
|
+
self.db.save_new_version(date_iso, md, note="autosave")
|
|
796
|
+
except Exception as e:
|
|
797
|
+
QMessageBox.critical(self, "Save Error", str(e))
|
|
522
798
|
|
|
523
799
|
def _on_text_changed(self):
|
|
524
800
|
self._dirty = True
|
|
@@ -581,18 +857,36 @@ class MainWindow(QMainWindow):
|
|
|
581
857
|
def _on_date_changed(self):
|
|
582
858
|
"""
|
|
583
859
|
When the calendar selection changes, save the previous day's note if dirty,
|
|
584
|
-
so we don't lose that text, then load the newly selected day.
|
|
860
|
+
so we don't lose that text, then load the newly selected day into current tab.
|
|
585
861
|
"""
|
|
862
|
+
# Skip if we're showing a context menu (right-click shouldn't load dates)
|
|
863
|
+
if getattr(self, "_showing_context_menu", False):
|
|
864
|
+
return
|
|
865
|
+
|
|
866
|
+
editor = self.current_editor()
|
|
867
|
+
if not editor:
|
|
868
|
+
return
|
|
869
|
+
|
|
586
870
|
# Stop pending autosave and persist current buffer if needed
|
|
587
871
|
try:
|
|
588
872
|
self._save_timer.stop()
|
|
589
873
|
except Exception:
|
|
590
874
|
pass
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
875
|
+
|
|
876
|
+
# Save the current editor's content if dirty
|
|
877
|
+
if hasattr(editor, "current_date") and self._dirty:
|
|
878
|
+
prev_date_iso = editor.current_date.toString("yyyy-MM-dd")
|
|
879
|
+
self._save_date(prev_date_iso, explicit=False)
|
|
880
|
+
|
|
881
|
+
# Now load the newly selected date into the current tab
|
|
882
|
+
new_date = self.calendar.selectedDate()
|
|
883
|
+
self._load_date_into_editor(new_date, editor)
|
|
884
|
+
editor.current_date = new_date
|
|
885
|
+
|
|
886
|
+
# Update tab title
|
|
887
|
+
current_index = self.tab_widget.currentIndex()
|
|
888
|
+
if current_index >= 0:
|
|
889
|
+
self.tab_widget.setTabText(current_index, new_date.toString("yyyy-MM-dd"))
|
|
596
890
|
|
|
597
891
|
def _save_date(self, date_iso: str, explicit: bool = False, note: str = "autosave"):
|
|
598
892
|
"""
|
|
@@ -617,10 +911,16 @@ class MainWindow(QMainWindow):
|
|
|
617
911
|
)
|
|
618
912
|
|
|
619
913
|
def _save_current(self, explicit: bool = False):
|
|
914
|
+
"""Save the current editor's content."""
|
|
620
915
|
try:
|
|
621
916
|
self._save_timer.stop()
|
|
622
917
|
except Exception:
|
|
623
918
|
pass
|
|
919
|
+
|
|
920
|
+
editor = self.current_editor()
|
|
921
|
+
if not editor or not hasattr(editor, "current_date"):
|
|
922
|
+
return
|
|
923
|
+
|
|
624
924
|
if explicit:
|
|
625
925
|
# Prompt for a note
|
|
626
926
|
dlg = SaveDialog(self)
|
|
@@ -629,8 +929,9 @@ class MainWindow(QMainWindow):
|
|
|
629
929
|
note = dlg.note_text()
|
|
630
930
|
else:
|
|
631
931
|
note = "autosave"
|
|
632
|
-
#
|
|
633
|
-
|
|
932
|
+
# Save the current editor's date
|
|
933
|
+
date_iso = editor.current_date.toString("yyyy-MM-dd")
|
|
934
|
+
self._save_date(date_iso, explicit, note)
|
|
634
935
|
try:
|
|
635
936
|
self._save_timer.start()
|
|
636
937
|
except Exception:
|
|
@@ -865,10 +1166,21 @@ If you want an encrypted backup, choose Backup instead of Export.
|
|
|
865
1166
|
self._idle_timer.start()
|
|
866
1167
|
|
|
867
1168
|
def eventFilter(self, obj, event):
|
|
1169
|
+
# Catch right-clicks on calendar BEFORE selectionChanged can fire
|
|
1170
|
+
if obj == self.calendar and event.type() == QEvent.MouseButtonPress:
|
|
1171
|
+
try:
|
|
1172
|
+
# QMouseEvent in PySide6
|
|
1173
|
+
if event.button() == Qt.RightButton:
|
|
1174
|
+
self._showing_context_menu = True
|
|
1175
|
+
except Exception:
|
|
1176
|
+
pass
|
|
1177
|
+
|
|
868
1178
|
if event.type() == QEvent.KeyPress and not self._locked:
|
|
869
1179
|
self._idle_timer.start()
|
|
1180
|
+
|
|
870
1181
|
if event.type() in (QEvent.ApplicationActivate, QEvent.WindowActivate):
|
|
871
1182
|
QTimer.singleShot(0, self._focus_editor_now)
|
|
1183
|
+
|
|
872
1184
|
return super().eventFilter(obj, event)
|
|
873
1185
|
|
|
874
1186
|
def _enter_lock(self):
|
|
@@ -920,8 +1232,11 @@ If you want an encrypted backup, choose Backup instead of Export.
|
|
|
920
1232
|
self.settings.setValue("main/windowState", self.saveState())
|
|
921
1233
|
self.settings.setValue("main/maximized", self.isMaximized())
|
|
922
1234
|
|
|
923
|
-
# Ensure we save
|
|
924
|
-
self.
|
|
1235
|
+
# Ensure we save all tabs before closing
|
|
1236
|
+
for i in range(self.tab_widget.count()):
|
|
1237
|
+
editor = self.tab_widget.widget(i)
|
|
1238
|
+
if editor:
|
|
1239
|
+
self._save_editor_content(editor)
|
|
925
1240
|
self.db.close()
|
|
926
1241
|
except Exception:
|
|
927
1242
|
pass
|
|
@@ -935,14 +1250,17 @@ If you want an encrypted backup, choose Backup instead of Export.
|
|
|
935
1250
|
return
|
|
936
1251
|
if not self.isActiveWindow():
|
|
937
1252
|
return
|
|
1253
|
+
editor = self.current_editor()
|
|
1254
|
+
if not editor:
|
|
1255
|
+
return
|
|
938
1256
|
# Belt-and-suspenders: do it now and once more on the next tick
|
|
939
|
-
|
|
940
|
-
|
|
1257
|
+
editor.setFocus(Qt.ActiveWindowFocusReason)
|
|
1258
|
+
editor.ensureCursorVisible()
|
|
941
1259
|
QTimer.singleShot(
|
|
942
1260
|
0,
|
|
943
1261
|
lambda: (
|
|
944
|
-
|
|
945
|
-
|
|
1262
|
+
editor.setFocus(Qt.ActiveWindowFocusReason) if editor else None,
|
|
1263
|
+
editor.ensureCursorVisible() if editor else None,
|
|
946
1264
|
),
|
|
947
1265
|
)
|
|
948
1266
|
|
|
@@ -957,8 +1275,15 @@ If you want an encrypted backup, choose Backup instead of Export.
|
|
|
957
1275
|
if ev.type() == QEvent.ActivationChange and self.isActiveWindow():
|
|
958
1276
|
QTimer.singleShot(0, self._focus_editor_now)
|
|
959
1277
|
|
|
960
|
-
def _set_editor_markdown_preserve_view(
|
|
961
|
-
|
|
1278
|
+
def _set_editor_markdown_preserve_view(
|
|
1279
|
+
self, markdown: str, editor: MarkdownEditor | None = None
|
|
1280
|
+
):
|
|
1281
|
+
if editor is None:
|
|
1282
|
+
editor = self.current_editor()
|
|
1283
|
+
if not editor:
|
|
1284
|
+
return
|
|
1285
|
+
|
|
1286
|
+
ed = editor
|
|
962
1287
|
|
|
963
1288
|
# Save caret/selection and scroll
|
|
964
1289
|
cur = ed.textCursor()
|
|
@@ -238,6 +238,12 @@ class MarkdownEditor(QTextEdit):
|
|
|
238
238
|
# Enable mouse tracking for checkbox clicking
|
|
239
239
|
self.viewport().setMouseTracking(True)
|
|
240
240
|
|
|
241
|
+
def setDocument(self, doc):
|
|
242
|
+
super().setDocument(doc)
|
|
243
|
+
# reattach the highlighter to the new document
|
|
244
|
+
if hasattr(self, "highlighter") and self.highlighter:
|
|
245
|
+
self.highlighter.setDocument(self.document())
|
|
246
|
+
|
|
241
247
|
def _on_text_changed(self):
|
|
242
248
|
"""Handle live formatting updates - convert checkbox markdown to Unicode."""
|
|
243
249
|
if self._updating:
|
|
@@ -245,53 +251,39 @@ class MarkdownEditor(QTextEdit):
|
|
|
245
251
|
|
|
246
252
|
self._updating = True
|
|
247
253
|
try:
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
#
|
|
255
|
-
#
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
254
|
+
c = self.textCursor()
|
|
255
|
+
block = c.block()
|
|
256
|
+
line = block.text()
|
|
257
|
+
pos_in_block = c.position() - block.position()
|
|
258
|
+
|
|
259
|
+
# Transform only this line:
|
|
260
|
+
# - "TODO " at start (with optional indent) -> "- ☐ "
|
|
261
|
+
# - "- [ ] " -> "- ☐ " and "- [x] " -> "- ☑ "
|
|
262
|
+
def transform_line(s: str) -> str:
|
|
263
|
+
s = s.replace("- [x] ", f"- {self._CHECK_CHECKED_DISPLAY} ")
|
|
264
|
+
s = s.replace("- [ ] ", f"- {self._CHECK_UNCHECKED_DISPLAY} ")
|
|
265
|
+
s = re.sub(
|
|
266
|
+
r"^([ \t]*)TODO\b[:\-]?\s+",
|
|
259
267
|
lambda m: f"{m.group(1)}- {self._CHECK_UNCHECKED_DISPLAY} ",
|
|
260
|
-
|
|
268
|
+
s,
|
|
261
269
|
)
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
checkbox_delta = (x_count + space_count) * 2
|
|
280
|
-
# Each "TODO " -> "- ☐ " shortens by 1 char
|
|
281
|
-
todo_count = len(list(todo_re.finditer(text_before)))
|
|
282
|
-
todo_delta = todo_count * 1
|
|
283
|
-
new_pos = pos - checkbox_delta - todo_delta
|
|
284
|
-
|
|
285
|
-
# Update the text
|
|
286
|
-
self.blockSignals(True)
|
|
287
|
-
self.setPlainText(modified_text)
|
|
288
|
-
self.blockSignals(False)
|
|
289
|
-
|
|
290
|
-
# Restore cursor position
|
|
291
|
-
cursor = self.textCursor()
|
|
292
|
-
cursor.setPosition(max(0, min(new_pos, len(modified_text))))
|
|
293
|
-
self.setTextCursor(cursor)
|
|
294
|
-
|
|
270
|
+
return s
|
|
271
|
+
|
|
272
|
+
new_line = transform_line(line)
|
|
273
|
+
if new_line != line:
|
|
274
|
+
# Replace just the current block
|
|
275
|
+
bc = QTextCursor(block)
|
|
276
|
+
bc.beginEditBlock()
|
|
277
|
+
bc.select(QTextCursor.BlockUnderCursor)
|
|
278
|
+
bc.insertText(new_line)
|
|
279
|
+
bc.endEditBlock()
|
|
280
|
+
|
|
281
|
+
# Restore cursor near its original visual position in the edited line
|
|
282
|
+
new_pos = min(
|
|
283
|
+
block.position() + len(new_line), block.position() + pos_in_block
|
|
284
|
+
)
|
|
285
|
+
c.setPosition(new_pos)
|
|
286
|
+
self.setTextCursor(c)
|
|
295
287
|
finally:
|
|
296
288
|
self._updating = False
|
|
297
289
|
|
|
@@ -359,6 +351,8 @@ class MarkdownEditor(QTextEdit):
|
|
|
359
351
|
self._updating = True
|
|
360
352
|
try:
|
|
361
353
|
self.setPlainText(display_text)
|
|
354
|
+
if hasattr(self, "highlighter") and self.highlighter:
|
|
355
|
+
self.highlighter.rehighlight()
|
|
362
356
|
finally:
|
|
363
357
|
self._updating = False
|
|
364
358
|
|
|
@@ -460,6 +454,30 @@ class MarkdownEditor(QTextEdit):
|
|
|
460
454
|
|
|
461
455
|
# Check if we're in a code block
|
|
462
456
|
current_block = cursor.block()
|
|
457
|
+
line_text = current_block.text()
|
|
458
|
+
pos_in_block = cursor.position() - current_block.position()
|
|
459
|
+
|
|
460
|
+
moved = False
|
|
461
|
+
i = 0
|
|
462
|
+
patterns = ["**", "__", "~~", "`", "*", "_"] # bold, italic, strike, code
|
|
463
|
+
# Consume stacked markers like **` if present
|
|
464
|
+
while True:
|
|
465
|
+
matched = False
|
|
466
|
+
for pat in patterns:
|
|
467
|
+
L = len(pat)
|
|
468
|
+
if line_text[pos_in_block + i : pos_in_block + i + L] == pat:
|
|
469
|
+
i += L
|
|
470
|
+
matched = True
|
|
471
|
+
moved = True
|
|
472
|
+
break
|
|
473
|
+
if not matched:
|
|
474
|
+
break
|
|
475
|
+
if moved:
|
|
476
|
+
cursor.movePosition(
|
|
477
|
+
QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.MoveAnchor, i
|
|
478
|
+
)
|
|
479
|
+
self.setTextCursor(cursor)
|
|
480
|
+
|
|
463
481
|
block_state = current_block.userState()
|
|
464
482
|
|
|
465
483
|
# If current line is opening code fence, or we're inside a code block
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|