0.2.1 with tabs

This commit is contained in:
Miguel Jacq 2025-11-09 16:19:51 +11:00
parent fa23cf4da9
commit f023224074
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
9 changed files with 651 additions and 60 deletions

View file

@ -1,3 +1,7 @@
# 0.2.1
* Introduce tabs!
# 0.2.0.1 # 0.2.0.1
* Fix chomping images when TODO is typed and converts to a checkbox * Fix chomping images when TODO is typed and converts to a checkbox

View file

@ -15,9 +15,7 @@ There is deliberately no network connectivity or syncing intended.
## Screenshot ## Screenshot
![Screenshot of Bouquin](./screenshot.png) ![Screenshot of Bouquin](https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshot.png)
![Screenshot of Bouquin in dark mode](./screenshot_dark.png)
## Features ## Features
@ -26,8 +24,9 @@ There is deliberately no network connectivity or syncing intended.
* Every 'page' is linked to the calendar day * Every 'page' is linked to the calendar day
* All changes are version controlled, with ability to view/diff versions and revert * All changes are version controlled, with ability to view/diff versions and revert
* Text is Markdown with basic styling * 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 * Images are supported
* Search * Search all pages, or find text on page (Ctrl+F)
* Automatic periodic saving (or explicitly save) * Automatic periodic saving (or explicitly save)
* Transparent integrity checking of the database when it opens * Transparent integrity checking of the database when it opens
* Automatic locking of the app after a period of inactivity (default 15 min) * Automatic locking of the app after a period of inactivity (default 15 min)

View file

@ -31,14 +31,19 @@ class FindBar(QWidget):
shortcut_parent: QWidget | None = None, shortcut_parent: QWidget | None = None,
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 = QHBoxLayout(self)
layout.setContentsMargins(6, 0, 6, 0) layout.setContentsMargins(6, 0, 6, 0)
layout.addWidget(QLabel("Find:")) layout.addWidget(QLabel("Find:"))
self.edit = QLineEdit(self) self.edit = QLineEdit(self)
self.edit.setPlaceholderText("Type to search…") self.edit.setPlaceholderText("Type to search…")
layout.addWidget(self.edit) layout.addWidget(self.edit)
@ -56,11 +61,15 @@ class FindBar(QWidget):
self.setVisible(False) self.setVisible(False)
# Shortcut escape key to close findBar # Shortcut (press Esc to hide bar)
sp = shortcut_parent if shortcut_parent is not None else (parent or self) sp = (
self._scEsc = QShortcut(Qt.Key_Escape, sp, activated=self._maybe_hide) 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.returnPressed.connect(self.find_next)
self.edit.textChanged.connect(self._update_highlight) self.edit.textChanged.connect(self._update_highlight)
self.case.toggled.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.prevBtn.clicked.connect(self.find_prev)
self.closeBtn.clicked.connect(self.hide_bar) 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 ----- # ----- Public API -----
def show_bar(self): def show_bar(self):
"""Show the bar, seed with current selection if sensible, focus the line edit.""" """Show the bar, seed with current selection if sensible, focus the line edit."""
if not self.editor:
return
tc = self.editor.textCursor() tc = self.editor.textCursor()
sel = tc.selectedText().strip() sel = tc.selectedText().strip()
if sel and "\u2029" not in sel: # ignore multi-paragraph selections if sel and "\u2029" not in sel: # ignore multi-paragraph selections
@ -105,6 +121,8 @@ class FindBar(QWidget):
return flags return flags
def find_next(self): def find_next(self):
if not self.editor:
return
txt = self.edit.text() txt = self.edit.text()
if not txt: if not txt:
return return
@ -130,6 +148,8 @@ class FindBar(QWidget):
self._update_highlight() self._update_highlight()
def find_prev(self): def find_prev(self):
if not self.editor:
return
txt = self.edit.text() txt = self.edit.text()
if not txt: if not txt:
return return
@ -155,6 +175,8 @@ class FindBar(QWidget):
self._update_highlight() self._update_highlight()
def _update_highlight(self): def _update_highlight(self):
if not self.editor:
return
txt = self.edit.text() txt = self.edit.text()
if not txt: if not txt:
self._clear_highlight() self._clear_highlight()
@ -183,4 +205,5 @@ class FindBar(QWidget):
self.editor.setExtraSelections(selections) self.editor.setExtraSelections(selections)
def _clear_highlight(self): def _clear_highlight(self):
if self.editor:
self.editor.setExtraSelections([]) self.editor.setExtraSelections([])

View file

@ -36,9 +36,12 @@ from PySide6.QtWidgets import (
QDialog, QDialog,
QFileDialog, QFileDialog,
QMainWindow, QMainWindow,
QMenu,
QMessageBox, QMessageBox,
QSizePolicy, QSizePolicy,
QSplitter, QSplitter,
QTableView,
QTabWidget,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
@ -98,34 +101,37 @@ class MainWindow(QMainWindow):
left_layout.addWidget(self.search) left_layout.addWidget(self.search)
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16) left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
# This is the note-taking editor # Create tab widget to hold multiple editors
self.editor = MarkdownEditor(self.themes) 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 # Toolbar for controlling styling
self.toolBar = ToolBar() self.toolBar = ToolBar()
self.addToolBar(self.toolBar) self.addToolBar(self.toolBar)
# Wire toolbar intents to editor methods self._bind_toolbar()
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.editor.currentCharFormatChanged.connect(lambda _f: self._sync_toolbar()) # Create the first editor tab
self.editor.cursorPositionChanged.connect(self._sync_toolbar) self._create_new_tab()
split = QSplitter() split = QSplitter()
split.addWidget(left_panel) split.addWidget(left_panel)
split.addWidget(self.editor) split.addWidget(self.tab_widget)
split.setStretchFactor(1, 1) 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() container = QWidget()
lay = QVBoxLayout(container) lay = QVBoxLayout(container)
lay.addWidget(split) lay.addWidget(split)
@ -162,7 +168,10 @@ class MainWindow(QMainWindow):
# Status bar for feedback # Status bar for feedback
self.statusBar().showMessage("Ready", 800) self.statusBar().showMessage("Ready", 800)
# Add findBar and add it to the statusBar # 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) self.statusBar().addPermanentWidget(self.findBar)
# When the findBar closes, put the caret back in the editor # When the findBar closes, put the caret back in the editor
self.findBar.closed.connect(self._focus_editor_now) self.findBar.closed.connect(self._focus_editor_now)
@ -258,7 +267,7 @@ class MainWindow(QMainWindow):
self._save_timer = QTimer(self) self._save_timer = QTimer(self)
self._save_timer.setSingleShot(True) self._save_timer.setSingleShot(True)
self._save_timer.timeout.connect(self._save_current) 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 # First load + mark dates in calendar with content
if not self._load_yesterday_todos(): if not self._load_yesterday_todos():
@ -313,6 +322,248 @@ class MainWindow(QMainWindow):
if self._try_connect(): if self._try_connect():
return True 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 (dont 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 arent 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): def _retheme_overrides(self):
if hasattr(self, "_lock_overlay"): if hasattr(self, "_lock_overlay"):
self._lock_overlay._apply_overlay_style() self._lock_overlay._apply_overlay_style()
@ -494,8 +745,28 @@ class MainWindow(QMainWindow):
return f"{d.year():04d}-{d.month():02d}-{d.day():02d}" return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
def _load_selected_date(self, date_iso=False, extra_data=False): 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: if not date_iso:
date_iso = self._current_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: try:
text = self.db.get_entry(date_iso) text = self.db.get_entry(date_iso)
if extra_data: if extra_data:
@ -504,21 +775,26 @@ class MainWindow(QMainWindow):
text += "\n" text += "\n"
text += extra_data text += extra_data
# Force a save now so we don't lose it. # 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._dirty = True
self._save_date(date_iso, True) self._save_date(date_iso, True)
except Exception as e: except Exception as e:
QMessageBox.critical(self, "Read Error", str(e)) QMessageBox.critical(self, "Read Error", str(e))
return return
self._set_editor_markdown_preserve_view(text) self._set_editor_markdown_preserve_view(text, editor)
self._dirty = False self._dirty = False
# track which date the editor currently represents
self._active_date_iso = date_iso def _save_editor_content(self, editor: MarkdownEditor):
qd = QDate.fromString(date_iso, "yyyy-MM-dd") """Save a specific editor's content to its associated date."""
self.calendar.setSelectedDate(qd) 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): def _on_text_changed(self):
self._dirty = True self._dirty = True
@ -581,18 +857,36 @@ class MainWindow(QMainWindow):
def _on_date_changed(self): def _on_date_changed(self):
""" """
When the calendar selection changes, save the previous day's note if dirty, 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 # Stop pending autosave and persist current buffer if needed
try: try:
self._save_timer.stop() self._save_timer.stop()
except Exception: except Exception:
pass pass
prev = getattr(self, "_active_date_iso", None)
if prev and self._dirty: # Save the current editor's content if dirty
self._save_date(prev, explicit=False) if hasattr(editor, "current_date") and self._dirty:
# Now load the newly selected date prev_date_iso = editor.current_date.toString("yyyy-MM-dd")
self._load_selected_date() 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"): 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): def _save_current(self, explicit: bool = False):
"""Save the current editor's content."""
try: try:
self._save_timer.stop() self._save_timer.stop()
except Exception: except Exception:
pass pass
editor = self.current_editor()
if not editor or not hasattr(editor, "current_date"):
return
if explicit: if explicit:
# Prompt for a note # Prompt for a note
dlg = SaveDialog(self) dlg = SaveDialog(self)
@ -629,8 +929,9 @@ class MainWindow(QMainWindow):
note = dlg.note_text() note = dlg.note_text()
else: else:
note = "autosave" note = "autosave"
# Delegate to _save_date for the currently selected date # Save the current editor's date
self._save_date(self._current_date_iso(), explicit, note) date_iso = editor.current_date.toString("yyyy-MM-dd")
self._save_date(date_iso, explicit, note)
try: try:
self._save_timer.start() self._save_timer.start()
except Exception: except Exception:
@ -865,10 +1166,21 @@ If you want an encrypted backup, choose Backup instead of Export.
self._idle_timer.start() self._idle_timer.start()
def eventFilter(self, obj, event): 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: if event.type() == QEvent.KeyPress and not self._locked:
self._idle_timer.start() self._idle_timer.start()
if event.type() in (QEvent.ApplicationActivate, QEvent.WindowActivate): if event.type() in (QEvent.ApplicationActivate, QEvent.WindowActivate):
QTimer.singleShot(0, self._focus_editor_now) QTimer.singleShot(0, self._focus_editor_now)
return super().eventFilter(obj, event) return super().eventFilter(obj, event)
def _enter_lock(self): 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/windowState", self.saveState())
self.settings.setValue("main/maximized", self.isMaximized()) self.settings.setValue("main/maximized", self.isMaximized())
# Ensure we save any last pending edits to the db # Ensure we save all tabs before closing
self._save_current() for i in range(self.tab_widget.count()):
editor = self.tab_widget.widget(i)
if editor:
self._save_editor_content(editor)
self.db.close() self.db.close()
except Exception: except Exception:
pass pass
@ -935,14 +1250,17 @@ If you want an encrypted backup, choose Backup instead of Export.
return return
if not self.isActiveWindow(): if not self.isActiveWindow():
return return
editor = self.current_editor()
if not editor:
return
# Belt-and-suspenders: do it now and once more on the next tick # Belt-and-suspenders: do it now and once more on the next tick
self.editor.setFocus(Qt.ActiveWindowFocusReason) editor.setFocus(Qt.ActiveWindowFocusReason)
self.editor.ensureCursorVisible() editor.ensureCursorVisible()
QTimer.singleShot( QTimer.singleShot(
0, 0,
lambda: ( lambda: (
self.editor.setFocus(Qt.ActiveWindowFocusReason), editor.setFocus(Qt.ActiveWindowFocusReason) if editor else None,
self.editor.ensureCursorVisible(), 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(): if ev.type() == QEvent.ActivationChange and self.isActiveWindow():
QTimer.singleShot(0, self._focus_editor_now) QTimer.singleShot(0, self._focus_editor_now)
def _set_editor_markdown_preserve_view(self, markdown: str): def _set_editor_markdown_preserve_view(
ed = self.editor 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 # Save caret/selection and scroll
cur = ed.textCursor() cur = ed.textCursor()

View file

@ -238,6 +238,12 @@ class MarkdownEditor(QTextEdit):
# Enable mouse tracking for checkbox clicking # Enable mouse tracking for checkbox clicking
self.viewport().setMouseTracking(True) 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): def _on_text_changed(self):
"""Handle live formatting updates - convert checkbox markdown to Unicode.""" """Handle live formatting updates - convert checkbox markdown to Unicode."""
if self._updating: if self._updating:
@ -257,7 +263,7 @@ class MarkdownEditor(QTextEdit):
s = s.replace("- [x] ", f"- {self._CHECK_CHECKED_DISPLAY} ") s = s.replace("- [x] ", f"- {self._CHECK_CHECKED_DISPLAY} ")
s = s.replace("- [ ] ", f"- {self._CHECK_UNCHECKED_DISPLAY} ") s = s.replace("- [ ] ", f"- {self._CHECK_UNCHECKED_DISPLAY} ")
s = re.sub( s = re.sub(
r'^([ \t]*)TODO\b[:\-]?\s+', r"^([ \t]*)TODO\b[:\-]?\s+",
lambda m: f"{m.group(1)}- {self._CHECK_UNCHECKED_DISPLAY} ", lambda m: f"{m.group(1)}- {self._CHECK_UNCHECKED_DISPLAY} ",
s, s,
) )
@ -273,8 +279,9 @@ class MarkdownEditor(QTextEdit):
bc.endEditBlock() bc.endEditBlock()
# Restore cursor near its original visual position in the edited line # Restore cursor near its original visual position in the edited line
new_pos = min(block.position() + len(new_line), new_pos = min(
block.position() + pos_in_block) block.position() + len(new_line), block.position() + pos_in_block
)
c.setPosition(new_pos) c.setPosition(new_pos)
self.setTextCursor(c) self.setTextCursor(c)
finally: finally:
@ -344,6 +351,8 @@ class MarkdownEditor(QTextEdit):
self._updating = True self._updating = True
try: try:
self.setPlainText(display_text) self.setPlainText(display_text)
if hasattr(self, "highlighter") and self.highlighter:
self.highlighter.rehighlight()
finally: finally:
self._updating = False self._updating = False
@ -445,6 +454,30 @@ class MarkdownEditor(QTextEdit):
# Check if we're in a code block # Check if we're in a code block
current_block = cursor.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() block_state = current_block.userState()
# If current line is opening code fence, or we're inside a code block # If current line is opening code fence, or we're inside a code block

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "bouquin" 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." description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
authors = ["Miguel Jacq <mig@mig5.net>"] authors = ["Miguel Jacq <mig@mig5.net>"]
readme = "README.md" readme = "README.md"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 437 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

207
tests/test_tabs.py Normal file
View file

@ -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"