0.2.1 with tabs
This commit is contained in:
parent
fa23cf4da9
commit
f023224074
9 changed files with 651 additions and 60 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,7 @@ There is deliberately no network connectivity or syncing intended.
|
||||||
|
|
||||||
## Screenshot
|
## Screenshot
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## 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)
|
||||||
|
|
|
||||||
|
|
@ -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([])
|
||||||
|
|
|
||||||
|
|
@ -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 (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):
|
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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
BIN
screenshot.png
BIN
screenshot.png
Binary file not shown.
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 437 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 116 KiB |
207
tests/test_tabs.py
Normal file
207
tests/test_tabs.py
Normal 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"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue