bouquin 0.1.12__py3-none-any.whl → 0.2.1.3__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
@@ -36,15 +36,18 @@ 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
  )
45
48
 
46
49
  from .db import DBManager
47
- from .editor import Editor
50
+ from .markdown_editor import MarkdownEditor
48
51
  from .find_bar import FindBar
49
52
  from .history_dialog import HistoryDialog
50
53
  from .key_prompt import KeyPrompt
@@ -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
- # This is the note-taking editor
102
- 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)
103
109
 
104
110
  # Toolbar for controlling styling
105
111
  self.toolBar = ToolBar()
106
112
  self.addToolBar(self.toolBar)
107
- # Wire toolbar intents to editor methods
108
- self.toolBar.boldRequested.connect(self.editor.apply_weight)
109
- self.toolBar.italicRequested.connect(self.editor.apply_italic)
110
- self.toolBar.underlineRequested.connect(self.editor.apply_underline)
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
- self.toolBar.alignRequested.connect(self.editor.setAlignment)
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.editor)
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
- self.findBar = FindBar(self.editor, shortcut_parent=self, parent=self)
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
- self.editor.textChanged.connect(self._on_text_changed)
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,277 @@ class MainWindow(QMainWindow):
313
322
  if self._try_connect():
314
323
  return True
315
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 -------------------#
316
596
  def _retheme_overrides(self):
317
597
  if hasattr(self, "_lock_overlay"):
318
598
  self._lock_overlay._apply_overlay_style()
@@ -447,20 +727,47 @@ class MainWindow(QMainWindow):
447
727
 
448
728
  # --- UI handlers ---------------------------------------------------------
449
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
+
450
760
  def _sync_toolbar(self):
451
761
  fmt = self.editor.currentCharFormat()
452
762
  c = self.editor.textCursor()
453
- bf = c.blockFormat()
454
763
 
455
764
  # Block signals so setChecked() doesn't re-trigger actions
456
765
  QSignalBlocker(self.toolBar.actBold)
457
766
  QSignalBlocker(self.toolBar.actItalic)
458
- QSignalBlocker(self.toolBar.actUnderline)
459
767
  QSignalBlocker(self.toolBar.actStrike)
460
768
 
461
769
  self.toolBar.actBold.setChecked(fmt.fontWeight() == QFont.Weight.Bold)
462
770
  self.toolBar.actItalic.setChecked(fmt.fontItalic())
463
- self.toolBar.actUnderline.setChecked(fmt.fontUnderline())
464
771
  self.toolBar.actStrike.setChecked(fmt.fontStrikeOut())
465
772
 
466
773
  # Headings: decide which to check by current point size
@@ -492,47 +799,60 @@ class MainWindow(QMainWindow):
492
799
  self.toolBar.actBullets.setChecked(bool(bullets_on))
493
800
  self.toolBar.actNumbers.setChecked(bool(numbers_on))
494
801
 
495
- # Alignment
496
- align = bf.alignment() & Qt.AlignHorizontal_Mask
497
- QSignalBlocker(self.toolBar.actAlignL)
498
- self.toolBar.actAlignL.setChecked(align == Qt.AlignLeft)
499
- QSignalBlocker(self.toolBar.actAlignC)
500
- self.toolBar.actAlignC.setChecked(align == Qt.AlignHCenter)
501
- QSignalBlocker(self.toolBar.actAlignR)
502
- self.toolBar.actAlignR.setChecked(align == Qt.AlignRight)
503
-
504
- def _current_date_iso(self) -> str:
505
- d = self.calendar.selectedDate()
506
- return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
507
-
508
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
+
509
808
  if not date_iso:
510
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")
511
828
  try:
512
829
  text = self.db.get_entry(date_iso)
513
830
  if extra_data:
514
- # Wrap extra_data in a <p> tag for HTML rendering
515
- extra_data_html = f"<p>{extra_data}</p>"
516
-
517
- # Inject the extra_data before the closing </body></html>
518
- modified = re.sub(r"(<\/body><\/html>)", extra_data_html + r"\1", text)
519
- text = modified
831
+ # Append extra data as markdown
832
+ if text and not text.endswith("\n"):
833
+ text += "\n"
834
+ text += extra_data
520
835
  # Force a save now so we don't lose it.
521
- self._set_editor_html_preserve_view(text)
836
+ self._set_editor_markdown_preserve_view(text, editor)
522
837
  self._dirty = True
523
838
  self._save_date(date_iso, True)
524
-
525
839
  except Exception as e:
526
840
  QMessageBox.critical(self, "Read Error", str(e))
527
841
  return
528
842
 
529
- self._set_editor_html_preserve_view(text)
530
-
843
+ self._set_editor_markdown_preserve_view(text, editor)
531
844
  self._dirty = False
532
- # track which date the editor currently represents
533
- self._active_date_iso = date_iso
534
- qd = QDate.fromString(date_iso, "yyyy-MM-dd")
535
- 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))
536
856
 
537
857
  def _on_text_changed(self):
538
858
  self._dirty = True
@@ -546,7 +866,7 @@ class MainWindow(QMainWindow):
546
866
  def _adjust_today(self):
547
867
  """Jump to today."""
548
868
  today = QDate.currentDate()
549
- self.calendar.setSelectedDate(today)
869
+ self._create_new_tab(today)
550
870
 
551
871
  def _load_yesterday_todos(self):
552
872
  try:
@@ -556,39 +876,33 @@ class MainWindow(QMainWindow):
556
876
  text = self.db.get_entry(yesterday_str)
557
877
  unchecked_items = []
558
878
 
559
- # Regex to match the unchecked checkboxes and their associated text
560
- checkbox_pattern = re.compile(
561
- r"<span[^>]*>(☐)</span>\s*(.*?)</p>", re.DOTALL
562
- )
563
-
564
- # Find unchecked items and store them
565
- for match in checkbox_pattern.finditer(text):
566
- checkbox = match.group(1) # Either ☐ or ☑
567
- item_text = match.group(2).strip() # The text after the checkbox
568
- if checkbox == "☐": # If it's an unchecked checkbox (☐)
569
- unchecked_items.append("☐ " + item_text) # Store the unchecked item
570
-
571
- # 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
572
896
  if unchecked_items:
573
- # This regex will find the entire checkbox line and remove it from the HTML content
574
- uncheckbox_pattern = re.compile(
575
- r"<span[^>]*>☐</span>\s*(.*?)</p>", re.DOTALL
576
- )
577
- modified_text = re.sub(
578
- uncheckbox_pattern, "", text
579
- ) # Remove the checkbox lines
580
-
581
- # Save the modified HTML back to the database
897
+ modified_text = "\n".join(remaining_lines)
582
898
  self.db.save_new_version(
583
899
  yesterday_str,
584
900
  modified_text,
585
901
  "Unchecked checkbox items moved to next day",
586
902
  )
587
903
 
588
- # Join unchecked items into a formatted string
589
- unchecked_str = "\n".join(
590
- [f"<p>{item}</p>" for item in unchecked_items]
591
- )
904
+ # Join unchecked items into markdown format
905
+ unchecked_str = "\n".join(unchecked_items) + "\n"
592
906
 
593
907
  # Load the unchecked items into the current editor
594
908
  self._load_selected_date(False, unchecked_str)
@@ -601,19 +915,65 @@ class MainWindow(QMainWindow):
601
915
  def _on_date_changed(self):
602
916
  """
603
917
  When the calendar selection changes, save the previous day's note if dirty,
604
- 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.
605
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
+
606
928
  # Stop pending autosave and persist current buffer if needed
607
929
  try:
608
930
  self._save_timer.stop()
609
931
  except Exception:
610
932
  pass
611
- prev = getattr(self, "_active_date_iso", None)
612
- if prev and self._dirty:
613
- self._save_date(prev, explicit=False)
614
- # Now load the newly selected date
615
- self._load_selected_date()
616
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 ---------------- #
617
977
  def _save_date(self, date_iso: str, explicit: bool = False, note: str = "autosave"):
618
978
  """
619
979
  Save editor contents into the given date. Shows status on success.
@@ -621,7 +981,7 @@ class MainWindow(QMainWindow):
621
981
  """
622
982
  if not self._dirty and not explicit:
623
983
  return
624
- text = self.editor.to_html_with_embedded_images()
984
+ text = self.editor.to_markdown()
625
985
  try:
626
986
  self.db.save_new_version(date_iso, text, note)
627
987
  except Exception as e:
@@ -637,10 +997,16 @@ class MainWindow(QMainWindow):
637
997
  )
638
998
 
639
999
  def _save_current(self, explicit: bool = False):
1000
+ """Save the current editor's content."""
640
1001
  try:
641
1002
  self._save_timer.stop()
642
1003
  except Exception:
643
1004
  pass
1005
+
1006
+ editor = self.current_editor()
1007
+ if not editor or not hasattr(editor, "current_date"):
1008
+ return
1009
+
644
1010
  if explicit:
645
1011
  # Prompt for a note
646
1012
  dlg = SaveDialog(self)
@@ -649,33 +1015,14 @@ class MainWindow(QMainWindow):
649
1015
  note = dlg.note_text()
650
1016
  else:
651
1017
  note = "autosave"
652
- # Delegate to _save_date for the currently selected date
653
- 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)
654
1021
  try:
655
1022
  self._save_timer.start()
656
1023
  except Exception:
657
1024
  pass
658
1025
 
659
- def _open_history(self):
660
- date_iso = self._current_date_iso()
661
- dlg = HistoryDialog(self.db, date_iso, self)
662
- if dlg.exec() == QDialog.Accepted:
663
- # refresh editor + calendar (head pointer may have changed)
664
- self._load_selected_date(date_iso)
665
- self._refresh_calendar_marks()
666
-
667
- def _on_insert_image(self):
668
- # Let the user pick one or many images
669
- paths, _ = QFileDialog.getOpenFileNames(
670
- self,
671
- "Insert image(s)",
672
- "",
673
- "Images (*.png *.jpg *.jpeg *.bmp *.gif *.webp)",
674
- )
675
- if not paths:
676
- return
677
- self.editor.insert_images(paths) # call into the editor
678
-
679
1026
  # ----------- Settings handler ------------#
680
1027
  def _open_settings(self):
681
1028
  dlg = SettingsDialog(self.cfg, self.db, self)
@@ -807,7 +1154,7 @@ If you want an encrypted backup, choose Backup instead of Export.
807
1154
  elif selected_filter.startswith("SQL"):
808
1155
  self.db.export_sql(filename)
809
1156
  else:
810
- self.db.export_by_extension(filename)
1157
+ raise ValueError("Unrecognised extension!")
811
1158
 
812
1159
  QMessageBox.information(self, "Export complete", f"Saved to:\n{filename}")
813
1160
  except Exception as e:
@@ -883,10 +1230,21 @@ If you want an encrypted backup, choose Backup instead of Export.
883
1230
  self._idle_timer.start()
884
1231
 
885
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
+
886
1242
  if event.type() == QEvent.KeyPress and not self._locked:
887
1243
  self._idle_timer.start()
1244
+
888
1245
  if event.type() in (QEvent.ApplicationActivate, QEvent.WindowActivate):
889
1246
  QTimer.singleShot(0, self._focus_editor_now)
1247
+
890
1248
  return super().eventFilter(obj, event)
891
1249
 
892
1250
  def _enter_lock(self):
@@ -938,8 +1296,11 @@ If you want an encrypted backup, choose Backup instead of Export.
938
1296
  self.settings.setValue("main/windowState", self.saveState())
939
1297
  self.settings.setValue("main/maximized", self.isMaximized())
940
1298
 
941
- # Ensure we save any last pending edits to the db
942
- 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)
943
1304
  self.db.close()
944
1305
  except Exception:
945
1306
  pass
@@ -953,14 +1314,17 @@ If you want an encrypted backup, choose Backup instead of Export.
953
1314
  return
954
1315
  if not self.isActiveWindow():
955
1316
  return
1317
+ editor = self.current_editor()
1318
+ if not editor:
1319
+ return
956
1320
  # Belt-and-suspenders: do it now and once more on the next tick
957
- self.editor.setFocus(Qt.ActiveWindowFocusReason)
958
- self.editor.ensureCursorVisible()
1321
+ editor.setFocus(Qt.ActiveWindowFocusReason)
1322
+ editor.ensureCursorVisible()
959
1323
  QTimer.singleShot(
960
1324
  0,
961
1325
  lambda: (
962
- self.editor.setFocus(Qt.ActiveWindowFocusReason),
963
- self.editor.ensureCursorVisible(),
1326
+ editor.setFocus(Qt.ActiveWindowFocusReason) if editor else None,
1327
+ editor.ensureCursorVisible() if editor else None,
964
1328
  ),
965
1329
  )
966
1330
 
@@ -975,8 +1339,15 @@ If you want an encrypted backup, choose Backup instead of Export.
975
1339
  if ev.type() == QEvent.ActivationChange and self.isActiveWindow():
976
1340
  QTimer.singleShot(0, self._focus_editor_now)
977
1341
 
978
- def _set_editor_html_preserve_view(self, html: str):
979
- ed = self.editor
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
980
1351
 
981
1352
  # Save caret/selection and scroll
982
1353
  cur = ed.textCursor()
@@ -986,15 +1357,19 @@ If you want an encrypted backup, choose Backup instead of Export.
986
1357
 
987
1358
  # Only touch the doc if it actually changed
988
1359
  ed.blockSignals(True)
989
- if ed.toHtml() != html:
990
- ed.setHtml(html)
1360
+ if ed.to_markdown() != markdown:
1361
+ ed.from_markdown(markdown)
991
1362
  ed.blockSignals(False)
992
1363
 
993
1364
  # Restore scroll first
994
1365
  ed.verticalScrollBar().setValue(v)
995
1366
  ed.horizontalScrollBar().setValue(h)
996
1367
 
997
- # Restore caret/selection
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
+
998
1373
  cur = ed.textCursor()
999
1374
  cur.setPosition(old_anchor)
1000
1375
  mode = (