bouquin 0.1.10__py3-none-any.whl → 0.2.1.2__py3-none-any.whl

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/main_window.py CHANGED
@@ -24,8 +24,10 @@ from PySide6.QtGui import (
24
24
  QDesktopServices,
25
25
  QFont,
26
26
  QGuiApplication,
27
+ QKeySequence,
27
28
  QPalette,
28
29
  QTextCharFormat,
30
+ QTextCursor,
29
31
  QTextListFormat,
30
32
  )
31
33
  from PySide6.QtWidgets import (
@@ -34,15 +36,19 @@ from PySide6.QtWidgets import (
34
36
  QDialog,
35
37
  QFileDialog,
36
38
  QMainWindow,
39
+ QMenu,
37
40
  QMessageBox,
38
41
  QSizePolicy,
39
42
  QSplitter,
43
+ QTableView,
44
+ QTabWidget,
40
45
  QVBoxLayout,
41
46
  QWidget,
42
47
  )
43
48
 
44
49
  from .db import DBManager
45
- from .editor import Editor
50
+ from .markdown_editor import MarkdownEditor
51
+ from .find_bar import FindBar
46
52
  from .history_dialog import HistoryDialog
47
53
  from .key_prompt import KeyPrompt
48
54
  from .lock_overlay import LockOverlay
@@ -95,33 +101,36 @@ class MainWindow(QMainWindow):
95
101
  left_layout.addWidget(self.search)
96
102
  left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
97
103
 
98
- # This is the note-taking editor
99
- self.editor = Editor(self.themes)
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)
100
109
 
101
110
  # Toolbar for controlling styling
102
111
  self.toolBar = ToolBar()
103
112
  self.addToolBar(self.toolBar)
104
- # Wire toolbar intents to editor methods
105
- self.toolBar.boldRequested.connect(self.editor.apply_weight)
106
- self.toolBar.italicRequested.connect(self.editor.apply_italic)
107
- self.toolBar.underlineRequested.connect(self.editor.apply_underline)
108
- self.toolBar.strikeRequested.connect(self.editor.apply_strikethrough)
109
- self.toolBar.codeRequested.connect(self.editor.apply_code)
110
- self.toolBar.headingRequested.connect(self.editor.apply_heading)
111
- self.toolBar.bulletsRequested.connect(self.editor.toggle_bullets)
112
- self.toolBar.numbersRequested.connect(self.editor.toggle_numbers)
113
- self.toolBar.checkboxesRequested.connect(self.editor.toggle_checkboxes)
114
- self.toolBar.alignRequested.connect(self.editor.setAlignment)
115
- self.toolBar.historyRequested.connect(self._open_history)
116
- self.toolBar.insertImageRequested.connect(self._on_insert_image)
117
-
118
- self.editor.currentCharFormatChanged.connect(lambda _f: self._sync_toolbar())
119
- self.editor.cursorPositionChanged.connect(self._sync_toolbar)
113
+ self._bind_toolbar()
114
+
115
+ # Create the first editor tab
116
+ self._create_new_tab()
120
117
 
121
118
  split = QSplitter()
122
119
  split.addWidget(left_panel)
123
- split.addWidget(self.editor)
124
- split.setStretchFactor(1, 1) # editor grows
120
+ split.addWidget(self.tab_widget)
121
+ split.setStretchFactor(1, 1)
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)
125
134
 
126
135
  container = QWidget()
127
136
  lay = QVBoxLayout(container)
@@ -146,8 +155,26 @@ class MainWindow(QMainWindow):
146
155
 
147
156
  QApplication.instance().installEventFilter(self)
148
157
 
158
+ # Focus on the editor
159
+ self.setFocusPolicy(Qt.StrongFocus)
160
+ self.editor.setFocusPolicy(Qt.StrongFocus)
161
+ self.toolBar.setFocusPolicy(Qt.NoFocus)
162
+ for w in self.toolBar.findChildren(QWidget):
163
+ w.setFocusPolicy(Qt.NoFocus)
164
+ QGuiApplication.instance().applicationStateChanged.connect(
165
+ self._on_app_state_changed
166
+ )
167
+
149
168
  # Status bar for feedback
150
169
  self.statusBar().showMessage("Ready", 800)
170
+ # Add findBar and add it to the statusBar
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
+ )
175
+ self.statusBar().addPermanentWidget(self.findBar)
176
+ # When the findBar closes, put the caret back in the editor
177
+ self.findBar.closed.connect(self._focus_editor_now)
151
178
 
152
179
  # Menu bar (File)
153
180
  mb = self.menuBar()
@@ -202,6 +229,24 @@ class MainWindow(QMainWindow):
202
229
  nav_menu.addAction(act_today)
203
230
  self.addAction(act_today)
204
231
 
232
+ act_find = QAction("Find on page", self)
233
+ act_find.setShortcut(QKeySequence.Find)
234
+ act_find.triggered.connect(self.findBar.show_bar)
235
+ nav_menu.addAction(act_find)
236
+ self.addAction(act_find)
237
+
238
+ act_find_next = QAction("Find Next", self)
239
+ act_find_next.setShortcut(QKeySequence.FindNext)
240
+ act_find_next.triggered.connect(self.findBar.find_next)
241
+ nav_menu.addAction(act_find_next)
242
+ self.addAction(act_find_next)
243
+
244
+ act_find_prev = QAction("Find Previous", self)
245
+ act_find_prev.setShortcut(QKeySequence.FindPrevious)
246
+ act_find_prev.triggered.connect(self.findBar.find_prev)
247
+ nav_menu.addAction(act_find_prev)
248
+ self.addAction(act_find_prev)
249
+
205
250
  # Help menu with drop-down
206
251
  help_menu = mb.addMenu("&Help")
207
252
  act_docs = QAction("Documentation", self)
@@ -222,7 +267,7 @@ class MainWindow(QMainWindow):
222
267
  self._save_timer = QTimer(self)
223
268
  self._save_timer.setSingleShot(True)
224
269
  self._save_timer.timeout.connect(self._save_current)
225
- self.editor.textChanged.connect(self._on_text_changed)
270
+ # Note: textChanged will be connected per-editor in _create_new_tab
226
271
 
227
272
  # First load + mark dates in calendar with content
228
273
  if not self._load_yesterday_todos():
@@ -277,11 +322,282 @@ class MainWindow(QMainWindow):
277
322
  if self._try_connect():
278
323
  return True
279
324
 
325
+ # ----------------- Tab and date management ----------------- #
326
+
327
+ def _current_date_iso(self) -> str:
328
+ d = self.calendar.selectedDate()
329
+ return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
330
+
331
+ def _date_key(self, qd: QDate) -> tuple[int, int, int]:
332
+ return (qd.year(), qd.month(), qd.day())
333
+
334
+ def _index_for_date_insert(self, date: QDate) -> int:
335
+ """Return the index where a tab for `date` should be inserted (ascending order)."""
336
+ key = self._date_key(date)
337
+ for i in range(self.tab_widget.count()):
338
+ w = self.tab_widget.widget(i)
339
+ d = getattr(w, "current_date", None)
340
+ if isinstance(d, QDate) and d.isValid():
341
+ if self._date_key(d) > key:
342
+ return i
343
+ return self.tab_widget.count()
344
+
345
+ def _reorder_tabs_by_date(self):
346
+ """Reorder existing tabs by their date (ascending)."""
347
+ bar = self.tab_widget.tabBar()
348
+ dated, undated = [], []
349
+
350
+ for i in range(self.tab_widget.count()):
351
+ w = self.tab_widget.widget(i)
352
+ d = getattr(w, "current_date", None)
353
+ if isinstance(d, QDate) and d.isValid():
354
+ dated.append((d, w))
355
+ else:
356
+ undated.append(w)
357
+
358
+ dated.sort(key=lambda t: self._date_key(t[0]))
359
+
360
+ with QSignalBlocker(self.tab_widget):
361
+ # Update labels to yyyy-MM-dd
362
+ for d, w in dated:
363
+ idx = self.tab_widget.indexOf(w)
364
+ if idx != -1:
365
+ self.tab_widget.setTabText(idx, d.toString("yyyy-MM-dd"))
366
+
367
+ # Move dated tabs into target positions 0..len(dated)-1
368
+ for target_pos, (_, w) in enumerate(dated):
369
+ cur = self.tab_widget.indexOf(w)
370
+ if cur != -1 and cur != target_pos:
371
+ bar.moveTab(cur, target_pos)
372
+
373
+ # Keep any undated pages (if they ever exist) after the dated ones
374
+ start = len(dated)
375
+ for offset, w in enumerate(undated):
376
+ cur = self.tab_widget.indexOf(w)
377
+ target = start + offset
378
+ if cur != -1 and cur != target:
379
+ bar.moveTab(cur, target)
380
+
381
+ def _tab_index_for_date(self, date: QDate) -> int:
382
+ """Return the index of the tab showing `date`, or -1 if none."""
383
+ iso = date.toString("yyyy-MM-dd")
384
+ for i in range(self.tab_widget.count()):
385
+ w = self.tab_widget.widget(i)
386
+ if (
387
+ hasattr(w, "current_date")
388
+ and w.current_date.toString("yyyy-MM-dd") == iso
389
+ ):
390
+ return i
391
+ return -1
392
+
393
+ def _open_date_in_tab(self, date: QDate):
394
+ """Focus existing tab for `date`, or create it if needed. Returns the editor."""
395
+ idx = self._tab_index_for_date(date)
396
+ if idx != -1:
397
+ self.tab_widget.setCurrentIndex(idx)
398
+ # keep calendar selection in sync (don’t trigger load)
399
+ from PySide6.QtCore import QSignalBlocker
400
+
401
+ with QSignalBlocker(self.calendar):
402
+ self.calendar.setSelectedDate(date)
403
+ QTimer.singleShot(0, self._focus_editor_now)
404
+ return self.tab_widget.widget(idx)
405
+ # not open yet -> create
406
+ return self._create_new_tab(date)
407
+
408
+ def _create_new_tab(self, date: QDate | None = None) -> MarkdownEditor:
409
+ if date is None:
410
+ date = self.calendar.selectedDate()
411
+
412
+ # Deduplicate: if already open, just jump there
413
+ existing = self._tab_index_for_date(date)
414
+ if existing != -1:
415
+ self.tab_widget.setCurrentIndex(existing)
416
+ return self.tab_widget.widget(existing)
417
+
418
+ """Create a new editor tab and return the editor instance."""
419
+ editor = MarkdownEditor(self.themes)
420
+
421
+ # Set up the editor's event connections
422
+ editor.currentCharFormatChanged.connect(lambda _f: self._sync_toolbar())
423
+ editor.cursorPositionChanged.connect(self._sync_toolbar)
424
+ editor.textChanged.connect(self._on_text_changed)
425
+
426
+ # Set tab title
427
+ tab_title = date.toString("yyyy-MM-dd")
428
+
429
+ # Add the tab
430
+ index = self.tab_widget.addTab(editor, tab_title)
431
+ self.tab_widget.setCurrentIndex(index)
432
+
433
+ # Load the date's content
434
+ self._load_date_into_editor(date, editor)
435
+
436
+ # Store the date with the editor so we can save it later
437
+ editor.current_date = date
438
+
439
+ # Insert at sorted position
440
+ tab_title = date.toString("yyyy-MM-dd")
441
+ pos = self._index_for_date_insert(date)
442
+ index = self.tab_widget.insertTab(pos, editor, tab_title)
443
+ self.tab_widget.setCurrentIndex(index)
444
+
445
+ return editor
446
+
447
+ def _close_tab(self, index: int):
448
+ """Close a tab at the given index."""
449
+ if self.tab_widget.count() <= 1:
450
+ # Don't close the last tab
451
+ return
452
+
453
+ editor = self.tab_widget.widget(index)
454
+ if editor:
455
+ # Save before closing
456
+ self._save_editor_content(editor)
457
+
458
+ self.tab_widget.removeTab(index)
459
+
460
+ def _on_tab_changed(self, index: int):
461
+ """Handle tab change - reconnect toolbar and sync UI."""
462
+ if index < 0:
463
+ return
464
+
465
+ editor = self.tab_widget.widget(index)
466
+ if editor and hasattr(editor, "current_date"):
467
+ # Update calendar selection to match the tab
468
+ with QSignalBlocker(self.calendar):
469
+ self.calendar.setSelectedDate(editor.current_date)
470
+
471
+ # Reconnect toolbar to new active editor
472
+ self._sync_toolbar()
473
+
474
+ # Focus the editor
475
+ QTimer.singleShot(0, self._focus_editor_now)
476
+
477
+ def _call_editor(self, method_name, *args):
478
+ """
479
+ Call the relevant method of the MarkdownEditor class on bind
480
+ """
481
+ ed = self.current_editor()
482
+ if ed is None:
483
+ return
484
+ getattr(ed, method_name)(*args)
485
+
486
+ def current_editor(self) -> MarkdownEditor | None:
487
+ """Get the currently active editor."""
488
+ return self.tab_widget.currentWidget()
489
+
490
+ @property
491
+ def editor(self) -> MarkdownEditor | None:
492
+ """Compatibility property to get current editor (for existing code)."""
493
+ return self.current_editor()
494
+
495
+ def _date_from_calendar_pos(self, pos) -> QDate | None:
496
+ """Translate a QCalendarWidget local pos to the QDate under the cursor."""
497
+ view: QTableView = self.calendar.findChild(
498
+ QTableView, "qt_calendar_calendarview"
499
+ )
500
+ if view is None:
501
+ return None
502
+
503
+ # Map calendar-local pos -> viewport pos
504
+ vp_pos = view.viewport().mapFrom(self.calendar, pos)
505
+ idx = view.indexAt(vp_pos)
506
+ if not idx.isValid():
507
+ return None
508
+
509
+ model = view.model()
510
+
511
+ # Account for optional headers
512
+ start_col = (
513
+ 0
514
+ if self.calendar.verticalHeaderFormat() == QCalendarWidget.NoVerticalHeader
515
+ else 1
516
+ )
517
+ start_row = (
518
+ 0
519
+ if self.calendar.horizontalHeaderFormat()
520
+ == QCalendarWidget.NoHorizontalHeader
521
+ else 1
522
+ )
523
+
524
+ # Find index of day 1 (first cell belonging to current month)
525
+ first_index = None
526
+ for r in range(start_row, model.rowCount()):
527
+ for c in range(start_col, model.columnCount()):
528
+ if model.index(r, c).data() == 1:
529
+ first_index = model.index(r, c)
530
+ break
531
+ if first_index:
532
+ break
533
+ if first_index is None:
534
+ return None
535
+
536
+ # Find index of the last day of the current month
537
+ last_day = (
538
+ QDate(self.calendar.yearShown(), self.calendar.monthShown(), 1)
539
+ .addMonths(1)
540
+ .addDays(-1)
541
+ .day()
542
+ )
543
+ last_index = None
544
+ for r in range(model.rowCount() - 1, first_index.row() - 1, -1):
545
+ for c in range(model.columnCount() - 1, start_col - 1, -1):
546
+ if model.index(r, c).data() == last_day:
547
+ last_index = model.index(r, c)
548
+ break
549
+ if last_index:
550
+ break
551
+ if last_index is None:
552
+ return None
553
+
554
+ # Determine if clicked cell belongs to prev/next month or current
555
+ day = int(idx.data())
556
+ year = self.calendar.yearShown()
557
+ month = self.calendar.monthShown()
558
+
559
+ before_first = (idx.row() < first_index.row()) or (
560
+ idx.row() == first_index.row() and idx.column() < first_index.column()
561
+ )
562
+ after_last = (idx.row() > last_index.row()) or (
563
+ idx.row() == last_index.row() and idx.column() > last_index.column()
564
+ )
565
+
566
+ if before_first:
567
+ if month == 1:
568
+ month = 12
569
+ year -= 1
570
+ else:
571
+ month -= 1
572
+ elif after_last:
573
+ if month == 12:
574
+ month = 1
575
+ year += 1
576
+ else:
577
+ month += 1
578
+
579
+ qd = QDate(year, month, day)
580
+ return qd if qd.isValid() else None
581
+
582
+ def _show_calendar_context_menu(self, pos):
583
+ self._showing_context_menu = True # so selectionChanged handler doesn't fire
584
+ clicked_date = self._date_from_calendar_pos(pos)
585
+
586
+ menu = QMenu(self)
587
+ open_in_new_tab_action = menu.addAction("Open in New Tab")
588
+ action = menu.exec_(self.calendar.mapToGlobal(pos))
589
+
590
+ self._showing_context_menu = False
591
+
592
+ if action == open_in_new_tab_action and clicked_date and clicked_date.isValid():
593
+ self._open_date_in_tab(clicked_date)
594
+
595
+ # ----------------- Some theme helpers -------------------#
280
596
  def _retheme_overrides(self):
281
597
  if hasattr(self, "_lock_overlay"):
282
598
  self._lock_overlay._apply_overlay_style()
283
599
  self._apply_calendar_text_colors()
284
- self._apply_link_css() # Reapply link styles based on the current theme
600
+ self._apply_link_css()
285
601
  self._apply_search_highlights(getattr(self, "_search_highlighted_dates", set()))
286
602
  self.calendar.update()
287
603
  self.editor.viewport().update()
@@ -298,7 +614,6 @@ class MainWindow(QMainWindow):
298
614
  css = "" # Default to no custom styling for links (system or light theme)
299
615
 
300
616
  try:
301
- # Apply to the editor (QTextEdit or any other relevant widgets)
302
617
  self.editor.document().setDefaultStyleSheet(css)
303
618
  except Exception:
304
619
  pass
@@ -347,7 +662,6 @@ class MainWindow(QMainWindow):
347
662
  self.calendar.setPalette(app_pal)
348
663
  self.calendar.setStyleSheet("")
349
664
 
350
- # Keep weekend text color in sync with the current palette
351
665
  self._apply_calendar_text_colors()
352
666
  self.calendar.update()
353
667
 
@@ -413,20 +727,47 @@ class MainWindow(QMainWindow):
413
727
 
414
728
  # --- UI handlers ---------------------------------------------------------
415
729
 
730
+ def _bind_toolbar(self):
731
+ if getattr(self, "_toolbar_bound", False):
732
+ return
733
+ tb = self.toolBar
734
+
735
+ # keep refs so we never create new lambdas (prevents accidental dupes)
736
+ self._tb_bold = lambda: self._call_editor("apply_weight")
737
+ self._tb_italic = lambda: self._call_editor("apply_italic")
738
+ self._tb_strike = lambda: self._call_editor("apply_strikethrough")
739
+ self._tb_code = lambda: self._call_editor("apply_code")
740
+ self._tb_heading = lambda level: self._call_editor("apply_heading", level)
741
+ self._tb_bullets = lambda: self._call_editor("toggle_bullets")
742
+ self._tb_numbers = lambda: self._call_editor("toggle_numbers")
743
+ self._tb_checkboxes = lambda: self._call_editor("toggle_checkboxes")
744
+
745
+ tb.boldRequested.connect(self._tb_bold)
746
+ tb.italicRequested.connect(self._tb_italic)
747
+ tb.strikeRequested.connect(self._tb_strike)
748
+ tb.codeRequested.connect(self._tb_code)
749
+ tb.headingRequested.connect(self._tb_heading)
750
+ tb.bulletsRequested.connect(self._tb_bullets)
751
+ tb.numbersRequested.connect(self._tb_numbers)
752
+ tb.checkboxesRequested.connect(self._tb_checkboxes)
753
+
754
+ # these aren’t editor methods
755
+ tb.historyRequested.connect(self._open_history)
756
+ tb.insertImageRequested.connect(self._on_insert_image)
757
+
758
+ self._toolbar_bound = True
759
+
416
760
  def _sync_toolbar(self):
417
761
  fmt = self.editor.currentCharFormat()
418
762
  c = self.editor.textCursor()
419
- bf = c.blockFormat()
420
763
 
421
764
  # Block signals so setChecked() doesn't re-trigger actions
422
765
  QSignalBlocker(self.toolBar.actBold)
423
766
  QSignalBlocker(self.toolBar.actItalic)
424
- QSignalBlocker(self.toolBar.actUnderline)
425
767
  QSignalBlocker(self.toolBar.actStrike)
426
768
 
427
769
  self.toolBar.actBold.setChecked(fmt.fontWeight() == QFont.Weight.Bold)
428
770
  self.toolBar.actItalic.setChecked(fmt.fontItalic())
429
- self.toolBar.actUnderline.setChecked(fmt.fontUnderline())
430
771
  self.toolBar.actStrike.setChecked(fmt.fontStrikeOut())
431
772
 
432
773
  # Headings: decide which to check by current point size
@@ -458,49 +799,60 @@ class MainWindow(QMainWindow):
458
799
  self.toolBar.actBullets.setChecked(bool(bullets_on))
459
800
  self.toolBar.actNumbers.setChecked(bool(numbers_on))
460
801
 
461
- # Alignment
462
- align = bf.alignment() & Qt.AlignHorizontal_Mask
463
- QSignalBlocker(self.toolBar.actAlignL)
464
- self.toolBar.actAlignL.setChecked(align == Qt.AlignLeft)
465
- QSignalBlocker(self.toolBar.actAlignC)
466
- self.toolBar.actAlignC.setChecked(align == Qt.AlignHCenter)
467
- QSignalBlocker(self.toolBar.actAlignR)
468
- self.toolBar.actAlignR.setChecked(align == Qt.AlignRight)
469
-
470
- def _current_date_iso(self) -> str:
471
- d = self.calendar.selectedDate()
472
- return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
473
-
474
802
  def _load_selected_date(self, date_iso=False, extra_data=False):
803
+ """Load a date into the current editor"""
804
+ editor = self.current_editor()
805
+ if not editor:
806
+ return
807
+
475
808
  if not date_iso:
476
809
  date_iso = self._current_date_iso()
810
+
811
+ qd = QDate.fromString(date_iso, "yyyy-MM-dd")
812
+ self._load_date_into_editor(qd, editor, extra_data)
813
+ editor.current_date = qd
814
+
815
+ # Update tab title
816
+ current_index = self.tab_widget.currentIndex()
817
+ if current_index >= 0:
818
+ self.tab_widget.setTabText(current_index, date_iso)
819
+
820
+ # Keep tabs sorted by date
821
+ self._reorder_tabs_by_date()
822
+
823
+ def _load_date_into_editor(
824
+ self, date: QDate, editor: MarkdownEditor, extra_data=False
825
+ ):
826
+ """Load a specific date's content into a given editor."""
827
+ date_iso = date.toString("yyyy-MM-dd")
477
828
  try:
478
829
  text = self.db.get_entry(date_iso)
479
830
  if extra_data:
480
- # Wrap extra_data in a <p> tag for HTML rendering
481
- extra_data_html = f"<p>{extra_data}</p>"
482
-
483
- # Inject the extra_data before the closing </body></html>
484
- modified = re.sub(r"(<\/body><\/html>)", extra_data_html + r"\1", text)
485
- text = modified
486
- self.editor.setHtml(text)
831
+ # Append extra data as markdown
832
+ if text and not text.endswith("\n"):
833
+ text += "\n"
834
+ text += extra_data
835
+ # Force a save now so we don't lose it.
836
+ self._set_editor_markdown_preserve_view(text, editor)
487
837
  self._dirty = True
488
838
  self._save_date(date_iso, True)
489
-
490
- print("end")
491
839
  except Exception as e:
492
840
  QMessageBox.critical(self, "Read Error", str(e))
493
841
  return
494
842
 
495
- self.editor.blockSignals(True)
496
- self.editor.setHtml(text)
497
- self.editor.blockSignals(False)
498
-
843
+ self._set_editor_markdown_preserve_view(text, editor)
499
844
  self._dirty = False
500
- # track which date the editor currently represents
501
- self._active_date_iso = date_iso
502
- qd = QDate.fromString(date_iso, "yyyy-MM-dd")
503
- self.calendar.setSelectedDate(qd)
845
+
846
+ def _save_editor_content(self, editor: MarkdownEditor):
847
+ """Save a specific editor's content to its associated date."""
848
+ if not hasattr(editor, "current_date"):
849
+ return
850
+ date_iso = editor.current_date.toString("yyyy-MM-dd")
851
+ try:
852
+ md = editor.to_markdown()
853
+ self.db.save_new_version(date_iso, md, note="autosave")
854
+ except Exception as e:
855
+ QMessageBox.critical(self, "Save Error", str(e))
504
856
 
505
857
  def _on_text_changed(self):
506
858
  self._dirty = True
@@ -514,7 +866,7 @@ class MainWindow(QMainWindow):
514
866
  def _adjust_today(self):
515
867
  """Jump to today."""
516
868
  today = QDate.currentDate()
517
- self.calendar.setSelectedDate(today)
869
+ self._create_new_tab(today)
518
870
 
519
871
  def _load_yesterday_todos(self):
520
872
  try:
@@ -524,39 +876,33 @@ class MainWindow(QMainWindow):
524
876
  text = self.db.get_entry(yesterday_str)
525
877
  unchecked_items = []
526
878
 
527
- # Regex to match the unchecked checkboxes and their associated text
528
- checkbox_pattern = re.compile(
529
- r"<span[^>]*>(☐)</span>\s*(.*?)</p>", re.DOTALL
530
- )
531
-
532
- # Find unchecked items and store them
533
- for match in checkbox_pattern.finditer(text):
534
- checkbox = match.group(1) # Either ☐ or ☑
535
- item_text = match.group(2).strip() # The text after the checkbox
536
- if checkbox == "☐": # If it's an unchecked checkbox (☐)
537
- unchecked_items.append("☐ " + item_text) # Store the unchecked item
538
-
539
- # Remove the unchecked items from yesterday's HTML content
879
+ # Split into lines and find unchecked checkbox items
880
+ lines = text.split("\n")
881
+ remaining_lines = []
882
+
883
+ for line in lines:
884
+ # Check for unchecked markdown checkboxes: - [ ] or - [☐]
885
+ if re.match(r"^\s*-\s*\[\s*\]\s+", line) or re.match(
886
+ r"^\s*-\s*\[☐\]\s+", line
887
+ ):
888
+ # Extract the text after the checkbox
889
+ item_text = re.sub(r"^\s*-\s*\[[\s]\]\s+", "", line)
890
+ unchecked_items.append(f"- [ ] {item_text}")
891
+ else:
892
+ # Keep all other lines
893
+ remaining_lines.append(line)
894
+
895
+ # Save modified content back if we moved items
540
896
  if unchecked_items:
541
- # This regex will find the entire checkbox line and remove it from the HTML content
542
- uncheckbox_pattern = re.compile(
543
- r"<span[^>]*>☐</span>\s*(.*?)</p>", re.DOTALL
544
- )
545
- modified_text = re.sub(
546
- uncheckbox_pattern, "", text
547
- ) # Remove the checkbox lines
548
-
549
- # Save the modified HTML back to the database
897
+ modified_text = "\n".join(remaining_lines)
550
898
  self.db.save_new_version(
551
899
  yesterday_str,
552
900
  modified_text,
553
901
  "Unchecked checkbox items moved to next day",
554
902
  )
555
903
 
556
- # Join unchecked items into a formatted string
557
- unchecked_str = "\n".join(
558
- [f"<p>{item}</p>" for item in unchecked_items]
559
- )
904
+ # Join unchecked items into markdown format
905
+ unchecked_str = "\n".join(unchecked_items) + "\n"
560
906
 
561
907
  # Load the unchecked items into the current editor
562
908
  self._load_selected_date(False, unchecked_str)
@@ -569,19 +915,65 @@ class MainWindow(QMainWindow):
569
915
  def _on_date_changed(self):
570
916
  """
571
917
  When the calendar selection changes, save the previous day's note if dirty,
572
- so we don't lose that text, then load the newly selected day.
918
+ so we don't lose that text, then load the newly selected day into current tab.
573
919
  """
920
+ # Skip if we're showing a context menu (right-click shouldn't load dates)
921
+ if getattr(self, "_showing_context_menu", False):
922
+ return
923
+
924
+ editor = self.current_editor()
925
+ if not editor:
926
+ return
927
+
574
928
  # Stop pending autosave and persist current buffer if needed
575
929
  try:
576
930
  self._save_timer.stop()
577
931
  except Exception:
578
932
  pass
579
- prev = getattr(self, "_active_date_iso", None)
580
- if prev and self._dirty:
581
- self._save_date(prev, explicit=False)
582
- # Now load the newly selected date
583
- self._load_selected_date()
584
933
 
934
+ # Save the current editor's content if dirty
935
+ if hasattr(editor, "current_date") and self._dirty:
936
+ prev_date_iso = editor.current_date.toString("yyyy-MM-dd")
937
+ self._save_date(prev_date_iso, explicit=False)
938
+
939
+ # Now load the newly selected date into the current tab
940
+ new_date = self.calendar.selectedDate()
941
+ self._load_date_into_editor(new_date, editor)
942
+ editor.current_date = new_date
943
+
944
+ # Update tab title
945
+ current_index = self.tab_widget.currentIndex()
946
+ if current_index >= 0:
947
+ self.tab_widget.setTabText(current_index, new_date.toString("yyyy-MM-dd"))
948
+
949
+ # Keep tabs sorted by date
950
+ self._reorder_tabs_by_date()
951
+
952
+ # ----------- History handler ------------#
953
+ def _open_history(self):
954
+ date_iso = self._current_date_iso()
955
+ dlg = HistoryDialog(self.db, date_iso, self)
956
+ if dlg.exec() == QDialog.Accepted:
957
+ # refresh editor + calendar (head pointer may have changed)
958
+ self._load_selected_date(date_iso)
959
+ self._refresh_calendar_marks()
960
+
961
+ # ----------- Image insert handler ------------#
962
+ def _on_insert_image(self):
963
+ # Let the user pick one or many images
964
+ paths, _ = QFileDialog.getOpenFileNames(
965
+ self,
966
+ "Insert image(s)",
967
+ "",
968
+ "Images (*.png *.jpg *.jpeg *.bmp *.gif *.webp)",
969
+ )
970
+ if not paths:
971
+ return
972
+ # Insert each image
973
+ for path_str in paths:
974
+ self.editor.insert_image_from_path(Path(path_str))
975
+
976
+ # --------------- Database saving of content ---------------- #
585
977
  def _save_date(self, date_iso: str, explicit: bool = False, note: str = "autosave"):
586
978
  """
587
979
  Save editor contents into the given date. Shows status on success.
@@ -589,7 +981,7 @@ class MainWindow(QMainWindow):
589
981
  """
590
982
  if not self._dirty and not explicit:
591
983
  return
592
- text = self.editor.to_html_with_embedded_images()
984
+ text = self.editor.to_markdown()
593
985
  try:
594
986
  self.db.save_new_version(date_iso, text, note)
595
987
  except Exception as e:
@@ -605,10 +997,16 @@ class MainWindow(QMainWindow):
605
997
  )
606
998
 
607
999
  def _save_current(self, explicit: bool = False):
1000
+ """Save the current editor's content."""
608
1001
  try:
609
1002
  self._save_timer.stop()
610
1003
  except Exception:
611
1004
  pass
1005
+
1006
+ editor = self.current_editor()
1007
+ if not editor or not hasattr(editor, "current_date"):
1008
+ return
1009
+
612
1010
  if explicit:
613
1011
  # Prompt for a note
614
1012
  dlg = SaveDialog(self)
@@ -617,33 +1015,14 @@ class MainWindow(QMainWindow):
617
1015
  note = dlg.note_text()
618
1016
  else:
619
1017
  note = "autosave"
620
- # Delegate to _save_date for the currently selected date
621
- self._save_date(self._current_date_iso(), explicit, note)
1018
+ # Save the current editor's date
1019
+ date_iso = editor.current_date.toString("yyyy-MM-dd")
1020
+ self._save_date(date_iso, explicit, note)
622
1021
  try:
623
1022
  self._save_timer.start()
624
1023
  except Exception:
625
1024
  pass
626
1025
 
627
- def _open_history(self):
628
- date_iso = self._current_date_iso()
629
- dlg = HistoryDialog(self.db, date_iso, self)
630
- if dlg.exec() == QDialog.Accepted:
631
- # refresh editor + calendar (head pointer may have changed)
632
- self._load_selected_date(date_iso)
633
- self._refresh_calendar_marks()
634
-
635
- def _on_insert_image(self):
636
- # Let the user pick one or many images
637
- paths, _ = QFileDialog.getOpenFileNames(
638
- self,
639
- "Insert image(s)",
640
- "",
641
- "Images (*.png *.jpg *.jpeg *.bmp *.gif *.webp)",
642
- )
643
- if not paths:
644
- return
645
- self.editor.insert_images(paths) # call into the editor
646
-
647
1026
  # ----------- Settings handler ------------#
648
1027
  def _open_settings(self):
649
1028
  dlg = SettingsDialog(self.cfg, self.db, self)
@@ -708,7 +1087,7 @@ class MainWindow(QMainWindow):
708
1087
  QGuiApplication.screenAt(QCursor.pos()) or QGuiApplication.primaryScreen()
709
1088
  )
710
1089
  r = screen.availableGeometry()
711
- # Center the window in that screens available area
1090
+ # Center the window in that screen's available area
712
1091
  self.move(r.center() - self.rect().center())
713
1092
 
714
1093
  # ----------------- Export handler ----------------- #
@@ -775,7 +1154,7 @@ If you want an encrypted backup, choose Backup instead of Export.
775
1154
  elif selected_filter.startswith("SQL"):
776
1155
  self.db.export_sql(filename)
777
1156
  else:
778
- self.db.export_by_extension(filename)
1157
+ raise ValueError("Unrecognised extension!")
779
1158
 
780
1159
  QMessageBox.information(self, "Export complete", f"Saved to:\n{filename}")
781
1160
  except Exception as e:
@@ -837,7 +1216,7 @@ If you want an encrypted backup, choose Backup instead of Export.
837
1216
  return
838
1217
  if minutes == 0:
839
1218
  self._idle_timer.stop()
840
- # If you’re currently locked, unlock when user disables the timer:
1219
+ # If currently locked, unlock when user disables the timer:
841
1220
  if getattr(self, "_locked", False):
842
1221
  try:
843
1222
  self._locked = False
@@ -851,11 +1230,27 @@ If you want an encrypted backup, choose Backup instead of Export.
851
1230
  self._idle_timer.start()
852
1231
 
853
1232
  def eventFilter(self, obj, event):
1233
+ # Catch right-clicks on calendar BEFORE selectionChanged can fire
1234
+ if obj == self.calendar and event.type() == QEvent.MouseButtonPress:
1235
+ try:
1236
+ # QMouseEvent in PySide6
1237
+ if event.button() == Qt.RightButton:
1238
+ self._showing_context_menu = True
1239
+ except Exception:
1240
+ pass
1241
+
854
1242
  if event.type() == QEvent.KeyPress and not self._locked:
855
1243
  self._idle_timer.start()
1244
+
1245
+ if event.type() in (QEvent.ApplicationActivate, QEvent.WindowActivate):
1246
+ QTimer.singleShot(0, self._focus_editor_now)
1247
+
856
1248
  return super().eventFilter(obj, event)
857
1249
 
858
1250
  def _enter_lock(self):
1251
+ """
1252
+ Trigger the lock overlay and disable widgets
1253
+ """
859
1254
  if self._locked:
860
1255
  return
861
1256
  self._locked = True
@@ -871,6 +1266,10 @@ If you want an encrypted backup, choose Backup instead of Export.
871
1266
 
872
1267
  @Slot()
873
1268
  def _on_unlock_clicked(self):
1269
+ """
1270
+ Prompt for key to unlock screen
1271
+ If successful, re-enable widgets
1272
+ """
874
1273
  try:
875
1274
  ok = self._prompt_for_key_until_valid(first_time=False)
876
1275
  except Exception as e:
@@ -887,6 +1286,7 @@ If you want an encrypted backup, choose Backup instead of Export.
887
1286
  if tb:
888
1287
  tb.setEnabled(True)
889
1288
  self._idle_timer.start()
1289
+ QTimer.singleShot(0, self._focus_editor_now)
890
1290
 
891
1291
  # ----------------- Close handlers ----------------- #
892
1292
  def closeEvent(self, event):
@@ -896,9 +1296,88 @@ If you want an encrypted backup, choose Backup instead of Export.
896
1296
  self.settings.setValue("main/windowState", self.saveState())
897
1297
  self.settings.setValue("main/maximized", self.isMaximized())
898
1298
 
899
- # Ensure we save any last pending edits to the db
900
- self._save_current()
1299
+ # Ensure we save all tabs before closing
1300
+ for i in range(self.tab_widget.count()):
1301
+ editor = self.tab_widget.widget(i)
1302
+ if editor:
1303
+ self._save_editor_content(editor)
901
1304
  self.db.close()
902
1305
  except Exception:
903
1306
  pass
904
1307
  super().closeEvent(event)
1308
+
1309
+ # ----------------- Below logic helps focus the editor ----------------- #
1310
+
1311
+ def _focus_editor_now(self):
1312
+ """Give focus to the editor and ensure the caret is visible."""
1313
+ if getattr(self, "_locked", False):
1314
+ return
1315
+ if not self.isActiveWindow():
1316
+ return
1317
+ editor = self.current_editor()
1318
+ if not editor:
1319
+ return
1320
+ # Belt-and-suspenders: do it now and once more on the next tick
1321
+ editor.setFocus(Qt.ActiveWindowFocusReason)
1322
+ editor.ensureCursorVisible()
1323
+ QTimer.singleShot(
1324
+ 0,
1325
+ lambda: (
1326
+ editor.setFocus(Qt.ActiveWindowFocusReason) if editor else None,
1327
+ editor.ensureCursorVisible() if editor else None,
1328
+ ),
1329
+ )
1330
+
1331
+ def _on_app_state_changed(self, state):
1332
+ # Called on macOS/Wayland/Windows when the whole app re-activates
1333
+ if state == Qt.ApplicationActive and self.isActiveWindow():
1334
+ QTimer.singleShot(0, self._focus_editor_now)
1335
+
1336
+ def changeEvent(self, ev):
1337
+ # Called on some platforms when the window's activation state flips
1338
+ super().changeEvent(ev)
1339
+ if ev.type() == QEvent.ActivationChange and self.isActiveWindow():
1340
+ QTimer.singleShot(0, self._focus_editor_now)
1341
+
1342
+ def _set_editor_markdown_preserve_view(
1343
+ self, markdown: str, editor: MarkdownEditor | None = None
1344
+ ):
1345
+ if editor is None:
1346
+ editor = self.current_editor()
1347
+ if not editor:
1348
+ return
1349
+
1350
+ ed = editor
1351
+
1352
+ # Save caret/selection and scroll
1353
+ cur = ed.textCursor()
1354
+ old_pos, old_anchor = cur.position(), cur.anchor()
1355
+ v = ed.verticalScrollBar().value()
1356
+ h = ed.horizontalScrollBar().value()
1357
+
1358
+ # Only touch the doc if it actually changed
1359
+ ed.blockSignals(True)
1360
+ if ed.to_markdown() != markdown:
1361
+ ed.from_markdown(markdown)
1362
+ ed.blockSignals(False)
1363
+
1364
+ # Restore scroll first
1365
+ ed.verticalScrollBar().setValue(v)
1366
+ ed.horizontalScrollBar().setValue(h)
1367
+
1368
+ # Restore caret/selection (bounded to new doc length)
1369
+ doc_length = ed.document().characterCount() - 1
1370
+ old_pos = min(old_pos, doc_length)
1371
+ old_anchor = min(old_anchor, doc_length)
1372
+
1373
+ cur = ed.textCursor()
1374
+ cur.setPosition(old_anchor)
1375
+ mode = (
1376
+ QTextCursor.KeepAnchor if old_anchor != old_pos else QTextCursor.MoveAnchor
1377
+ )
1378
+ cur.setPosition(old_pos, mode)
1379
+ ed.setTextCursor(cur)
1380
+
1381
+ # Refresh highlights if the theme changed
1382
+ if hasattr(self, "findBar"):
1383
+ self.findBar.refresh()