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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: bouquin
3
- Version: 0.2.0
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
- ![Screenshot of Bouquin](./screenshot.png)
39
-
40
- ![Screenshot of Bouquin in dark mode](./screenshot_dark.png)
38
+ ![Screenshot of Bouquin](https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshot.png)
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
- ![Screenshot of Bouquin](./screenshot.png)
19
-
20
- ![Screenshot of Bouquin in dark mode](./screenshot_dark.png)
18
+ ![Screenshot of Bouquin](https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshot.png)
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
- # UI
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 escape key to close findBar
60
- sp = shortcut_parent if shortcut_parent is not None else (parent or self)
61
- self._scEsc = QShortcut(Qt.Key_Escape, sp, activated=self._maybe_hide)
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.setExtraSelections([])
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
- # This is the note-taking editor
102
- self.editor = MarkdownEditor(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
- # Note: Markdown doesn't support underline, so we skip underlineRequested
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.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,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
- # track which date the editor currently represents
519
- self._active_date_iso = date_iso
520
- qd = QDate.fromString(date_iso, "yyyy-MM-dd")
521
- self.calendar.setSelectedDate(qd)
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
- prev = getattr(self, "_active_date_iso", None)
592
- if prev and self._dirty:
593
- self._save_date(prev, explicit=False)
594
- # Now load the newly selected date
595
- self._load_selected_date()
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
- # Delegate to _save_date for the currently selected date
633
- self._save_date(self._current_date_iso(), explicit, note)
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 any last pending edits to the db
924
- self._save_current()
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
- self.editor.setFocus(Qt.ActiveWindowFocusReason)
940
- self.editor.ensureCursorVisible()
1257
+ editor.setFocus(Qt.ActiveWindowFocusReason)
1258
+ editor.ensureCursorVisible()
941
1259
  QTimer.singleShot(
942
1260
  0,
943
1261
  lambda: (
944
- self.editor.setFocus(Qt.ActiveWindowFocusReason),
945
- self.editor.ensureCursorVisible(),
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(self, markdown: str):
961
- ed = self.editor
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
- # Convert checkbox markdown to Unicode for display
249
- cursor = self.textCursor()
250
- pos = cursor.position()
251
-
252
- text = self.toPlainText()
253
-
254
- # Convert lines that START with "TODO " into an unchecked checkbox.
255
- # Keeps any leading indentation.
256
- todo_re = re.compile(r"(?m)^([ \t]*)TODO\s")
257
- if todo_re.search(text):
258
- modified_text = todo_re.sub(
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
- text,
268
+ s,
261
269
  )
262
- else:
263
- modified_text = text
264
-
265
- # Replace checkbox markdown with Unicode (for display only)
266
- modified_text = modified_text.replace(
267
- "- [x] ", f"- {self._CHECK_CHECKED_DISPLAY} "
268
- )
269
- modified_text = modified_text.replace(
270
- "- [ ] ", f"- {self._CHECK_UNCHECKED_DISPLAY} "
271
- )
272
-
273
- if modified_text != text:
274
- # Count replacements before cursor to adjust position
275
- text_before = text[:pos]
276
- x_count = text_before.count("- [x] ")
277
- space_count = text_before.count("- [ ] ")
278
- # Each markdown checkbox -> unicode shortens by 2 chars ([x]/[ ] -> ☑/☐)
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
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "bouquin"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
5
5
  authors = ["Miguel Jacq <mig@mig5.net>"]
6
6
  readme = "README.md"
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