diff --git a/CHANGELOG.md b/CHANGELOG.md index f2961b0..3e05a4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 0.2.1 + + * Introduce tabs! + # 0.2.0.1 * Fix chomping images when TODO is typed and converts to a checkbox diff --git a/README.md b/README.md index 2cc03ef..bd037ee 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,7 @@ There is deliberately no network connectivity or syncing intended. ## Screenshot -![Screenshot of Bouquin](./screenshot.png) - -![Screenshot of Bouquin in dark mode](./screenshot_dark.png) +![Screenshot of Bouquin](https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshot.png) ## Features @@ -26,8 +24,9 @@ There is deliberately no network connectivity or syncing intended. * Every 'page' is linked to the calendar day * All changes are version controlled, with ability to view/diff versions and revert * Text is Markdown with basic styling + * Tabs are supported - right-click on a date from the calendar to open it in a new tab. * Images are supported - * Search + * Search all pages, or find text on page (Ctrl+F) * Automatic periodic saving (or explicitly save) * Transparent integrity checking of the database when it opens * Automatic locking of the app after a period of inactivity (default 15 min) diff --git a/bouquin/find_bar.py b/bouquin/find_bar.py index 47490d6..8f88449 100644 --- a/bouquin/find_bar.py +++ b/bouquin/find_bar.py @@ -31,14 +31,19 @@ class FindBar(QWidget): shortcut_parent: QWidget | None = None, parent: QWidget | None = None, ): - super().__init__(parent) - self.editor = editor - # UI + super().__init__(parent) + + # store how to get the current editor + self._editor_getter = editor if callable(editor) else (lambda: editor) + self.shortcut_parent = shortcut_parent + + # UI (build ONCE) layout = QHBoxLayout(self) layout.setContentsMargins(6, 0, 6, 0) layout.addWidget(QLabel("Find:")) + self.edit = QLineEdit(self) self.edit.setPlaceholderText("Type to search…") layout.addWidget(self.edit) @@ -56,11 +61,15 @@ class FindBar(QWidget): self.setVisible(False) - # Shortcut escape key to close findBar - sp = shortcut_parent if shortcut_parent is not None else (parent or self) - self._scEsc = QShortcut(Qt.Key_Escape, sp, activated=self._maybe_hide) + # Shortcut (press Esc to hide bar) + sp = ( + self.shortcut_parent + if self.shortcut_parent is not None + else (self.parent() or self) + ) + QShortcut(Qt.Key_Escape, sp, activated=self._maybe_hide) - # Signals + # Signals (connect ONCE) self.edit.returnPressed.connect(self.find_next) self.edit.textChanged.connect(self._update_highlight) self.case.toggled.connect(self._update_highlight) @@ -68,10 +77,17 @@ class FindBar(QWidget): self.prevBtn.clicked.connect(self.find_prev) self.closeBtn.clicked.connect(self.hide_bar) + @property + def editor(self) -> QTextEdit | None: + """Get the current editor (no side effects).""" + return self._editor_getter() + # ----- Public API ----- def show_bar(self): """Show the bar, seed with current selection if sensible, focus the line edit.""" + if not self.editor: + return tc = self.editor.textCursor() sel = tc.selectedText().strip() if sel and "\u2029" not in sel: # ignore multi-paragraph selections @@ -105,6 +121,8 @@ class FindBar(QWidget): return flags def find_next(self): + if not self.editor: + return txt = self.edit.text() if not txt: return @@ -130,6 +148,8 @@ class FindBar(QWidget): self._update_highlight() def find_prev(self): + if not self.editor: + return txt = self.edit.text() if not txt: return @@ -155,6 +175,8 @@ class FindBar(QWidget): self._update_highlight() def _update_highlight(self): + if not self.editor: + return txt = self.edit.text() if not txt: self._clear_highlight() @@ -183,4 +205,5 @@ class FindBar(QWidget): self.editor.setExtraSelections(selections) def _clear_highlight(self): - self.editor.setExtraSelections([]) + if self.editor: + self.editor.setExtraSelections([]) diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 234be2b..6418726 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -36,9 +36,12 @@ from PySide6.QtWidgets import ( QDialog, QFileDialog, QMainWindow, + QMenu, QMessageBox, QSizePolicy, QSplitter, + QTableView, + QTabWidget, QVBoxLayout, QWidget, ) @@ -98,34 +101,37 @@ class MainWindow(QMainWindow): left_layout.addWidget(self.search) left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16) - # This is the note-taking editor - self.editor = MarkdownEditor(self.themes) + # Create tab widget to hold multiple editors + self.tab_widget = QTabWidget() + self.tab_widget.setTabsClosable(True) + self.tab_widget.tabCloseRequested.connect(self._close_tab) + self.tab_widget.currentChanged.connect(self._on_tab_changed) # Toolbar for controlling styling self.toolBar = ToolBar() self.addToolBar(self.toolBar) - # Wire toolbar intents to editor methods - self.toolBar.boldRequested.connect(self.editor.apply_weight) - self.toolBar.italicRequested.connect(self.editor.apply_italic) - # Note: Markdown doesn't support underline, so we skip underlineRequested - self.toolBar.strikeRequested.connect(self.editor.apply_strikethrough) - self.toolBar.codeRequested.connect(self.editor.apply_code) - self.toolBar.headingRequested.connect(self.editor.apply_heading) - self.toolBar.bulletsRequested.connect(self.editor.toggle_bullets) - self.toolBar.numbersRequested.connect(self.editor.toggle_numbers) - self.toolBar.checkboxesRequested.connect(self.editor.toggle_checkboxes) - # Note: Markdown doesn't natively support alignment, removing alignRequested - self.toolBar.historyRequested.connect(self._open_history) - self.toolBar.insertImageRequested.connect(self._on_insert_image) + self._bind_toolbar() - self.editor.currentCharFormatChanged.connect(lambda _f: self._sync_toolbar()) - self.editor.cursorPositionChanged.connect(self._sync_toolbar) + # Create the first editor tab + self._create_new_tab() split = QSplitter() split.addWidget(left_panel) - split.addWidget(self.editor) + split.addWidget(self.tab_widget) split.setStretchFactor(1, 1) + # Enable context menu on calendar for opening dates in new tabs + self.calendar.setContextMenuPolicy(Qt.CustomContextMenu) + self.calendar.customContextMenuRequested.connect( + self._show_calendar_context_menu + ) + + # Flag to prevent _on_date_changed when showing context menu + self._showing_context_menu = False + + # Install event filter to catch right-clicks before selectionChanged fires + self.calendar.installEventFilter(self) + container = QWidget() lay = QVBoxLayout(container) lay.addWidget(split) @@ -162,7 +168,10 @@ class MainWindow(QMainWindow): # Status bar for feedback self.statusBar().showMessage("Ready", 800) # Add findBar and add it to the statusBar - self.findBar = FindBar(self.editor, shortcut_parent=self, parent=self) + # FindBar will get the current editor dynamically via a callable + self.findBar = FindBar( + lambda: self.current_editor(), shortcut_parent=self, parent=self + ) self.statusBar().addPermanentWidget(self.findBar) # When the findBar closes, put the caret back in the editor self.findBar.closed.connect(self._focus_editor_now) @@ -258,7 +267,7 @@ class MainWindow(QMainWindow): self._save_timer = QTimer(self) self._save_timer.setSingleShot(True) self._save_timer.timeout.connect(self._save_current) - self.editor.textChanged.connect(self._on_text_changed) + # Note: textChanged will be connected per-editor in _create_new_tab # First load + mark dates in calendar with content if not self._load_yesterday_todos(): @@ -313,6 +322,248 @@ class MainWindow(QMainWindow): if self._try_connect(): return True + # ----------------- Tab management ----------------- # + + def _tab_index_for_date(self, date: QDate) -> int: + """Return the index of the tab showing `date`, or -1 if none.""" + iso = date.toString("yyyy-MM-dd") + for i in range(self.tab_widget.count()): + w = self.tab_widget.widget(i) + if ( + hasattr(w, "current_date") + and w.current_date.toString("yyyy-MM-dd") == iso + ): + return i + return -1 + + def _open_date_in_tab(self, date: QDate): + """Focus existing tab for `date`, or create it if needed. Returns the editor.""" + idx = self._tab_index_for_date(date) + if idx != -1: + self.tab_widget.setCurrentIndex(idx) + # keep calendar selection in sync (don’t trigger load) + from PySide6.QtCore import QSignalBlocker + + with QSignalBlocker(self.calendar): + self.calendar.setSelectedDate(date) + QTimer.singleShot(0, self._focus_editor_now) + return self.tab_widget.widget(idx) + # not open yet -> create + return self._create_new_tab(date) + + def _create_new_tab(self, date: QDate | None = None) -> MarkdownEditor: + if date is None: + date = self.calendar.selectedDate() + + # Deduplicate: if already open, just jump there + existing = self._tab_index_for_date(date) + if existing != -1: + self.tab_widget.setCurrentIndex(existing) + return self.tab_widget.widget(existing) + + """Create a new editor tab and return the editor instance.""" + editor = MarkdownEditor(self.themes) + + # Set up the editor's event connections + editor.currentCharFormatChanged.connect(lambda _f: self._sync_toolbar()) + editor.cursorPositionChanged.connect(self._sync_toolbar) + editor.textChanged.connect(self._on_text_changed) + + # Determine tab title + if date is None: + date = self.calendar.selectedDate() + tab_title = date.toString("yyyy-MM-dd") + + # Add the tab + index = self.tab_widget.addTab(editor, tab_title) + self.tab_widget.setCurrentIndex(index) + + # Load the date's content + self._load_date_into_editor(date, editor) + + # Store the date with the editor so we can save it later + editor.current_date = date + + return editor + + def _close_tab(self, index: int): + """Close a tab at the given index.""" + if self.tab_widget.count() <= 1: + # Don't close the last tab + return + + editor = self.tab_widget.widget(index) + if editor: + # Save before closing + self._save_editor_content(editor) + + self.tab_widget.removeTab(index) + + def _on_tab_changed(self, index: int): + """Handle tab change - reconnect toolbar and sync UI.""" + if index < 0: + return + + editor = self.tab_widget.widget(index) + if editor and hasattr(editor, "current_date"): + # Update calendar selection to match the tab + with QSignalBlocker(self.calendar): + self.calendar.setSelectedDate(editor.current_date) + + # Reconnect toolbar to new active editor + self._sync_toolbar() + + # Focus the editor + QTimer.singleShot(0, self._focus_editor_now) + + def _call_editor(self, method_name, *args): + """ + Call the relevant method of the MarkdownEditor class on bind + """ + ed = self.current_editor() + if ed is None: + return + getattr(ed, method_name)(*args) + + def _bind_toolbar(self): + if getattr(self, "_toolbar_bound", False): + return + tb = self.toolBar + + # keep refs so we never create new lambdas (prevents accidental dupes) + self._tb_bold = lambda: self._call_editor("apply_weight") + self._tb_italic = lambda: self._call_editor("apply_italic") + self._tb_strike = lambda: self._call_editor("apply_strikethrough") + self._tb_code = lambda: self._call_editor("apply_code") + self._tb_heading = lambda level: self._call_editor("apply_heading", level) + self._tb_bullets = lambda: self._call_editor("toggle_bullets") + self._tb_numbers = lambda: self._call_editor("toggle_numbers") + self._tb_checkboxes = lambda: self._call_editor("toggle_checkboxes") + + tb.boldRequested.connect(self._tb_bold) + tb.italicRequested.connect(self._tb_italic) + tb.strikeRequested.connect(self._tb_strike) + tb.codeRequested.connect(self._tb_code) + tb.headingRequested.connect(self._tb_heading) + tb.bulletsRequested.connect(self._tb_bullets) + tb.numbersRequested.connect(self._tb_numbers) + tb.checkboxesRequested.connect(self._tb_checkboxes) + + # these aren’t editor methods + tb.historyRequested.connect(self._open_history) + tb.insertImageRequested.connect(self._on_insert_image) + + self._toolbar_bound = True + + def current_editor(self) -> MarkdownEditor | None: + """Get the currently active editor.""" + return self.tab_widget.currentWidget() + + @property + def editor(self) -> MarkdownEditor | None: + """Compatibility property to get current editor (for existing code).""" + return self.current_editor() + + def _date_from_calendar_pos(self, pos) -> QDate | None: + """Translate a QCalendarWidget local pos to the QDate under the cursor.""" + view: QTableView = self.calendar.findChild( + QTableView, "qt_calendar_calendarview" + ) + if view is None: + return None + + # Map calendar-local pos -> viewport pos + vp_pos = view.viewport().mapFrom(self.calendar, pos) + idx = view.indexAt(vp_pos) + if not idx.isValid(): + return None + + model = view.model() + + # Account for optional headers + start_col = ( + 0 + if self.calendar.verticalHeaderFormat() == QCalendarWidget.NoVerticalHeader + else 1 + ) + start_row = ( + 0 + if self.calendar.horizontalHeaderFormat() + == QCalendarWidget.NoHorizontalHeader + else 1 + ) + + # Find index of day 1 (first cell belonging to current month) + first_index = None + for r in range(start_row, model.rowCount()): + for c in range(start_col, model.columnCount()): + if model.index(r, c).data() == 1: + first_index = model.index(r, c) + break + if first_index: + break + if first_index is None: + return None + + # Find index of the last day of the current month + last_day = ( + QDate(self.calendar.yearShown(), self.calendar.monthShown(), 1) + .addMonths(1) + .addDays(-1) + .day() + ) + last_index = None + for r in range(model.rowCount() - 1, first_index.row() - 1, -1): + for c in range(model.columnCount() - 1, start_col - 1, -1): + if model.index(r, c).data() == last_day: + last_index = model.index(r, c) + break + if last_index: + break + if last_index is None: + return None + + # Determine if clicked cell belongs to prev/next month or current + day = int(idx.data()) + year = self.calendar.yearShown() + month = self.calendar.monthShown() + + before_first = (idx.row() < first_index.row()) or ( + idx.row() == first_index.row() and idx.column() < first_index.column() + ) + after_last = (idx.row() > last_index.row()) or ( + idx.row() == last_index.row() and idx.column() > last_index.column() + ) + + if before_first: + if month == 1: + month = 12 + year -= 1 + else: + month -= 1 + elif after_last: + if month == 12: + month = 1 + year += 1 + else: + month += 1 + + qd = QDate(year, month, day) + return qd if qd.isValid() else None + + def _show_calendar_context_menu(self, pos): + self._showing_context_menu = True # so selectionChanged handler doesn't fire + clicked_date = self._date_from_calendar_pos(pos) + + menu = QMenu(self) + open_in_new_tab_action = menu.addAction("Open in New Tab") + action = menu.exec_(self.calendar.mapToGlobal(pos)) + + self._showing_context_menu = False + + if action == open_in_new_tab_action and clicked_date and clicked_date.isValid(): + self._open_date_in_tab(clicked_date) + def _retheme_overrides(self): if hasattr(self, "_lock_overlay"): self._lock_overlay._apply_overlay_style() @@ -494,8 +745,28 @@ class MainWindow(QMainWindow): return f"{d.year():04d}-{d.month():02d}-{d.day():02d}" def _load_selected_date(self, date_iso=False, extra_data=False): + """Load a date into the current editor (backward compatibility).""" + editor = self.current_editor() + if not editor: + return + if not date_iso: date_iso = self._current_date_iso() + + qd = QDate.fromString(date_iso, "yyyy-MM-dd") + self._load_date_into_editor(qd, editor, extra_data) + editor.current_date = qd + + # Update tab title + current_index = self.tab_widget.currentIndex() + if current_index >= 0: + self.tab_widget.setTabText(current_index, date_iso) + + def _load_date_into_editor( + self, date: QDate, editor: MarkdownEditor, extra_data=False + ): + """Load a specific date's content into a given editor.""" + date_iso = date.toString("yyyy-MM-dd") try: text = self.db.get_entry(date_iso) if extra_data: @@ -504,21 +775,26 @@ class MainWindow(QMainWindow): text += "\n" text += extra_data # Force a save now so we don't lose it. - self._set_editor_markdown_preserve_view(text) + self._set_editor_markdown_preserve_view(text, editor) self._dirty = True self._save_date(date_iso, True) - except Exception as e: QMessageBox.critical(self, "Read Error", str(e)) return - self._set_editor_markdown_preserve_view(text) - + self._set_editor_markdown_preserve_view(text, editor) self._dirty = False - # track which date the editor currently represents - self._active_date_iso = date_iso - qd = QDate.fromString(date_iso, "yyyy-MM-dd") - self.calendar.setSelectedDate(qd) + + def _save_editor_content(self, editor: MarkdownEditor): + """Save a specific editor's content to its associated date.""" + if not hasattr(editor, "current_date"): + return + date_iso = editor.current_date.toString("yyyy-MM-dd") + try: + md = editor.to_markdown() + self.db.save_new_version(date_iso, md, note="autosave") + except Exception as e: + QMessageBox.critical(self, "Save Error", str(e)) def _on_text_changed(self): self._dirty = True @@ -581,18 +857,36 @@ class MainWindow(QMainWindow): def _on_date_changed(self): """ When the calendar selection changes, save the previous day's note if dirty, - so we don't lose that text, then load the newly selected day. + so we don't lose that text, then load the newly selected day into current tab. """ + # Skip if we're showing a context menu (right-click shouldn't load dates) + if getattr(self, "_showing_context_menu", False): + return + + editor = self.current_editor() + if not editor: + return + # Stop pending autosave and persist current buffer if needed try: self._save_timer.stop() except Exception: pass - prev = getattr(self, "_active_date_iso", None) - if prev and self._dirty: - self._save_date(prev, explicit=False) - # Now load the newly selected date - self._load_selected_date() + + # Save the current editor's content if dirty + if hasattr(editor, "current_date") and self._dirty: + prev_date_iso = editor.current_date.toString("yyyy-MM-dd") + self._save_date(prev_date_iso, explicit=False) + + # Now load the newly selected date into the current tab + new_date = self.calendar.selectedDate() + self._load_date_into_editor(new_date, editor) + editor.current_date = new_date + + # Update tab title + current_index = self.tab_widget.currentIndex() + if current_index >= 0: + self.tab_widget.setTabText(current_index, new_date.toString("yyyy-MM-dd")) def _save_date(self, date_iso: str, explicit: bool = False, note: str = "autosave"): """ @@ -617,10 +911,16 @@ class MainWindow(QMainWindow): ) def _save_current(self, explicit: bool = False): + """Save the current editor's content.""" try: self._save_timer.stop() except Exception: pass + + editor = self.current_editor() + if not editor or not hasattr(editor, "current_date"): + return + if explicit: # Prompt for a note dlg = SaveDialog(self) @@ -629,8 +929,9 @@ class MainWindow(QMainWindow): note = dlg.note_text() else: note = "autosave" - # Delegate to _save_date for the currently selected date - self._save_date(self._current_date_iso(), explicit, note) + # Save the current editor's date + date_iso = editor.current_date.toString("yyyy-MM-dd") + self._save_date(date_iso, explicit, note) try: self._save_timer.start() except Exception: @@ -865,10 +1166,21 @@ If you want an encrypted backup, choose Backup instead of Export. self._idle_timer.start() def eventFilter(self, obj, event): + # Catch right-clicks on calendar BEFORE selectionChanged can fire + if obj == self.calendar and event.type() == QEvent.MouseButtonPress: + try: + # QMouseEvent in PySide6 + if event.button() == Qt.RightButton: + self._showing_context_menu = True + except Exception: + pass + if event.type() == QEvent.KeyPress and not self._locked: self._idle_timer.start() + if event.type() in (QEvent.ApplicationActivate, QEvent.WindowActivate): QTimer.singleShot(0, self._focus_editor_now) + return super().eventFilter(obj, event) def _enter_lock(self): @@ -920,8 +1232,11 @@ If you want an encrypted backup, choose Backup instead of Export. self.settings.setValue("main/windowState", self.saveState()) self.settings.setValue("main/maximized", self.isMaximized()) - # Ensure we save any last pending edits to the db - self._save_current() + # Ensure we save all tabs before closing + for i in range(self.tab_widget.count()): + editor = self.tab_widget.widget(i) + if editor: + self._save_editor_content(editor) self.db.close() except Exception: pass @@ -935,14 +1250,17 @@ If you want an encrypted backup, choose Backup instead of Export. return if not self.isActiveWindow(): return + editor = self.current_editor() + if not editor: + return # Belt-and-suspenders: do it now and once more on the next tick - self.editor.setFocus(Qt.ActiveWindowFocusReason) - self.editor.ensureCursorVisible() + editor.setFocus(Qt.ActiveWindowFocusReason) + editor.ensureCursorVisible() QTimer.singleShot( 0, lambda: ( - self.editor.setFocus(Qt.ActiveWindowFocusReason), - self.editor.ensureCursorVisible(), + editor.setFocus(Qt.ActiveWindowFocusReason) if editor else None, + editor.ensureCursorVisible() if editor else None, ), ) @@ -957,8 +1275,15 @@ If you want an encrypted backup, choose Backup instead of Export. if ev.type() == QEvent.ActivationChange and self.isActiveWindow(): QTimer.singleShot(0, self._focus_editor_now) - def _set_editor_markdown_preserve_view(self, markdown: str): - ed = self.editor + def _set_editor_markdown_preserve_view( + self, markdown: str, editor: MarkdownEditor | None = None + ): + if editor is None: + editor = self.current_editor() + if not editor: + return + + ed = editor # Save caret/selection and scroll cur = ed.textCursor() diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py index 760507b..87ccfac 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -238,6 +238,12 @@ class MarkdownEditor(QTextEdit): # Enable mouse tracking for checkbox clicking self.viewport().setMouseTracking(True) + def setDocument(self, doc): + super().setDocument(doc) + # reattach the highlighter to the new document + if hasattr(self, "highlighter") and self.highlighter: + self.highlighter.setDocument(self.document()) + def _on_text_changed(self): """Handle live formatting updates - convert checkbox markdown to Unicode.""" if self._updating: @@ -257,7 +263,7 @@ class MarkdownEditor(QTextEdit): s = s.replace("- [x] ", f"- {self._CHECK_CHECKED_DISPLAY} ") s = s.replace("- [ ] ", f"- {self._CHECK_UNCHECKED_DISPLAY} ") s = re.sub( - r'^([ \t]*)TODO\b[:\-]?\s+', + r"^([ \t]*)TODO\b[:\-]?\s+", lambda m: f"{m.group(1)}- {self._CHECK_UNCHECKED_DISPLAY} ", s, ) @@ -273,8 +279,9 @@ class MarkdownEditor(QTextEdit): bc.endEditBlock() # Restore cursor near its original visual position in the edited line - new_pos = min(block.position() + len(new_line), - block.position() + pos_in_block) + new_pos = min( + block.position() + len(new_line), block.position() + pos_in_block + ) c.setPosition(new_pos) self.setTextCursor(c) finally: @@ -344,6 +351,8 @@ class MarkdownEditor(QTextEdit): self._updating = True try: self.setPlainText(display_text) + if hasattr(self, "highlighter") and self.highlighter: + self.highlighter.rehighlight() finally: self._updating = False @@ -445,6 +454,30 @@ class MarkdownEditor(QTextEdit): # Check if we're in a code block current_block = cursor.block() + line_text = current_block.text() + pos_in_block = cursor.position() - current_block.position() + + moved = False + i = 0 + patterns = ["**", "__", "~~", "`", "*", "_"] # bold, italic, strike, code + # Consume stacked markers like **` if present + while True: + matched = False + for pat in patterns: + L = len(pat) + if line_text[pos_in_block + i : pos_in_block + i + L] == pat: + i += L + matched = True + moved = True + break + if not matched: + break + if moved: + cursor.movePosition( + QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.MoveAnchor, i + ) + self.setTextCursor(cursor) + block_state = current_block.userState() # If current line is opening code fence, or we're inside a code block diff --git a/pyproject.toml b/pyproject.toml index 870eb0e..d32b190 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bouquin" -version = "0.2.0.1" +version = "0.2.1" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel Jacq "] readme = "README.md" diff --git a/screenshot.png b/screenshot.png index e0843e5..ba6f5e9 100644 Binary files a/screenshot.png and b/screenshot.png differ diff --git a/screenshot_dark.png b/screenshot_dark.png deleted file mode 100644 index e9b4b8c..0000000 Binary files a/screenshot_dark.png and /dev/null differ diff --git a/tests/test_tabs.py b/tests/test_tabs.py new file mode 100644 index 0000000..637ff26 --- /dev/null +++ b/tests/test_tabs.py @@ -0,0 +1,207 @@ +import types +import pytest +from PySide6.QtWidgets import QFileDialog +from PySide6.QtGui import QTextCursor + + +from bouquin.theme import ThemeManager, ThemeConfig, Theme +from bouquin.settings import get_settings +from bouquin.main_window import MainWindow +from bouquin.history_dialog import HistoryDialog + + +@pytest.mark.gui +def test_tabs_open_and_deduplicate(qtbot, app, tmp_db_cfg, fresh_db): + # point to the temp encrypted DB + s = get_settings() + s.setValue("db/path", str(tmp_db_cfg.path)) + s.setValue("db/key", tmp_db_cfg.key) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + w = MainWindow(themes=themes) + qtbot.addWidget(w) + w.show() + + # first tab is today's date + date1 = w.calendar.selectedDate() + initial_count = w.tab_widget.count() + + # opening the same date should NOT create a new tab + w._open_date_in_tab(date1) + assert w.tab_widget.count() == initial_count + assert w.tab_widget.currentWidget().current_date == date1 + + # opening a different date should create exactly one new tab + date2 = date1.addDays(1) + w._open_date_in_tab(date2) + assert w.tab_widget.count() == initial_count + 1 + assert w.tab_widget.currentWidget().current_date == date2 + + # jumping back to date1 just focuses the existing tab + w._open_date_in_tab(date1) + assert w.tab_widget.count() == initial_count + 1 + assert w.tab_widget.currentWidget().current_date == date1 + + +@pytest.mark.gui +def test_toolbar_signals_dispatch_once_per_click( + qtbot, app, tmp_db_cfg, fresh_db, monkeypatch +): + s = get_settings() + s.setValue("db/path", str(tmp_db_cfg.path)) + s.setValue("db/key", tmp_db_cfg.key) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + w = MainWindow(themes=themes) + qtbot.addWidget(w) + w.show() + + tb = w.toolBar + + # Spy on the first tab's editor + ed1 = w.current_editor() + calls1 = { + "bold": 0, + "italic": 0, + "strike": 0, + "code": 0, + "heading": 0, + "bullets": 0, + "numbers": 0, + "checkboxes": 0, + } + + def mk(key): + def _spy(self, *a, **k): + calls1[key] += 1 + + return _spy + + ed1.apply_weight = types.MethodType(mk("bold"), ed1) + ed1.apply_italic = types.MethodType(mk("italic"), ed1) + ed1.apply_strikethrough = types.MethodType(mk("strike"), ed1) + ed1.apply_code = types.MethodType(mk("code"), ed1) + ed1.apply_heading = types.MethodType(mk("heading"), ed1) + ed1.toggle_bullets = types.MethodType(mk("bullets"), ed1) + ed1.toggle_numbers = types.MethodType(mk("numbers"), ed1) + ed1.toggle_checkboxes = types.MethodType(mk("checkboxes"), ed1) + + # Click all the things once + tb.boldRequested.emit() + tb.italicRequested.emit() + tb.strikeRequested.emit() + tb.codeRequested.emit() + tb.headingRequested.emit(24) + tb.bulletsRequested.emit() + tb.numbersRequested.emit() + tb.checkboxesRequested.emit() + + assert all(v == 1 for v in calls1.values()) # fired once each + + # Switch to a new tab and make sure clicks go ONLY to the active editor + date2 = w.calendar.selectedDate().addDays(1) + w._open_date_in_tab(date2) + ed2 = w.current_editor() + calls2 = {"bold": 0} + ed2.apply_weight = types.MethodType( + lambda self: calls2.__setitem__("bold", calls2["bold"] + 1), ed2 + ) + + tb.boldRequested.emit() + assert calls1["bold"] == 1 + assert calls2["bold"] == 1 + + w._open_date_in_tab(date2.addDays(-1)) # back to first tab + tb.boldRequested.emit() + assert calls1["bold"] == 2 + assert calls2["bold"] == 1 + + +@pytest.mark.gui +def test_history_and_insert_image_not_duplicated( + qtbot, app, tmp_db_cfg, fresh_db, monkeypatch, tmp_path +): + s = get_settings() + s.setValue("db/path", str(tmp_db_cfg.path)) + s.setValue("db/key", tmp_db_cfg.key) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + w = MainWindow(themes=themes) + qtbot.addWidget(w) + w.show() + + # History dialog opens exactly once + opened = {"count": 0} + + def fake_exec(self): + opened["count"] += 1 + return 0 # Rejected + + monkeypatch.setattr(HistoryDialog, "exec", fake_exec, raising=False) + w.toolBar.historyRequested.emit() + assert opened["count"] == 1 + + # Insert image: simulate user selecting one file, and ensure it's inserted once + dummy = tmp_path / "x.png" + dummy.write_bytes(b"\x89PNG\r\n\x1a\n") + inserted = {"count": 0} + ed = w.current_editor() + + def fake_insert(self, p): + inserted["count"] += 1 + + ed.insert_image_from_path = types.MethodType(fake_insert, ed) + monkeypatch.setattr( + QFileDialog, + "getOpenFileNames", + lambda *a, **k: ([str(dummy)], "Images (*.png)"), + raising=False, + ) + w.toolBar.insertImageRequested.emit() + assert inserted["count"] == 1 + + +@pytest.mark.gui +def test_highlighter_attached_after_text_load(qtbot, app, tmp_db_cfg, fresh_db): + s = get_settings() + s.setValue("db/path", str(tmp_db_cfg.path)) + s.setValue("db/key", tmp_db_cfg.key) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + w = MainWindow(themes=themes) + qtbot.addWidget(w) + w.show() + + ed = w.current_editor() + ed.from_markdown("**bold**\n- [ ] task\n~~strike~~") + assert ed.highlighter is not None + assert ed.highlighter.document() is ed.document() + + +@pytest.mark.gui +def test_findbar_works_for_current_tab(qtbot, app, tmp_db_cfg, fresh_db): + s = get_settings() + s.setValue("db/path", str(tmp_db_cfg.path)) + s.setValue("db/key", tmp_db_cfg.key) + + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + w = MainWindow(themes=themes) + qtbot.addWidget(w) + w.show() + + # Tab 1 content + ed1 = w.current_editor() + ed1.from_markdown("alpha bravo charlie") + w.findBar.show_bar() + w.findBar.edit.setText("bravo") + w.findBar.find_next() + assert ed1.textCursor().selectedText() == "bravo" + + # Tab 2 content (contains the query too) + date2 = w.calendar.selectedDate().addDays(1) + w._open_date_in_tab(date2) + ed2 = w.current_editor() + ed2.from_markdown("x bravo y bravo z") + ed2.moveCursor(QTextCursor.Start) + w.findBar.find_next() + assert ed2.textCursor().selectedText() == "bravo"