diff --git a/CHANGELOG.md b/CHANGELOG.md index acf8dd7..0f3262f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 0.1.12 + + * Add find bar for searching for text in the editor + # 0.1.11 * Add missing export extensions to export_by_extension diff --git a/bouquin/find_bar.py b/bouquin/find_bar.py new file mode 100644 index 0000000..47490d6 --- /dev/null +++ b/bouquin/find_bar.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import ( + QShortcut, + QTextCursor, + QTextCharFormat, + QTextDocument, +) +from PySide6.QtWidgets import ( + QWidget, + QHBoxLayout, + QLineEdit, + QLabel, + QPushButton, + QCheckBox, + QTextEdit, +) + + +class FindBar(QWidget): + """Widget for finding text in the Editor""" + + closed = ( + Signal() + ) # emitted when the bar is hidden (Esc/✕), so caller can refocus editor + + def __init__( + self, + editor: QTextEdit, + shortcut_parent: QWidget | None = None, + parent: QWidget | None = None, + ): + super().__init__(parent) + self.editor = editor + + # UI + 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) + + self.case = QCheckBox("Match case", self) + layout.addWidget(self.case) + + self.prevBtn = QPushButton("Prev", self) + self.nextBtn = QPushButton("Next", self) + self.closeBtn = QPushButton("✕", self) + self.closeBtn.setFlat(True) + layout.addWidget(self.prevBtn) + layout.addWidget(self.nextBtn) + layout.addWidget(self.closeBtn) + + 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) + + # Signals + self.edit.returnPressed.connect(self.find_next) + self.edit.textChanged.connect(self._update_highlight) + self.case.toggled.connect(self._update_highlight) + self.nextBtn.clicked.connect(self.find_next) + self.prevBtn.clicked.connect(self.find_prev) + self.closeBtn.clicked.connect(self.hide_bar) + + # ----- Public API ----- + + def show_bar(self): + """Show the bar, seed with current selection if sensible, focus the line edit.""" + tc = self.editor.textCursor() + sel = tc.selectedText().strip() + if sel and "\u2029" not in sel: # ignore multi-paragraph selections + self.edit.setText(sel) + self.setVisible(True) + self.edit.setFocus(Qt.ShortcutFocusReason) + self.edit.selectAll() + self._update_highlight() + + def hide_bar(self): + self.setVisible(False) + self._clear_highlight() + self.closed.emit() + + def refresh(self): + """Recompute highlights""" + self._update_highlight() + + # ----- Internals ----- + + def _maybe_hide(self): + if self.isVisible(): + self.hide_bar() + + def _flags(self, backward: bool = False) -> QTextDocument.FindFlags: + flags = QTextDocument.FindFlags() + if backward: + flags |= QTextDocument.FindBackward + if self.case.isChecked(): + flags |= QTextDocument.FindCaseSensitively + return flags + + def find_next(self): + txt = self.edit.text() + if not txt: + return + # If current selection == query, bump caret to the end so we don't re-match it. + c = self.editor.textCursor() + if c.hasSelection(): + sel = c.selectedText() + same = ( + (sel == txt) + if self.case.isChecked() + else (sel.casefold() == txt.casefold()) + ) + if same: + end = max(c.position(), c.anchor()) + c.setPosition(end, QTextCursor.MoveAnchor) + self.editor.setTextCursor(c) + if not self.editor.find(txt, self._flags(False)): + cur = self.editor.textCursor() + cur.movePosition(QTextCursor.Start) + self.editor.setTextCursor(cur) + self.editor.find(txt, self._flags(False)) + self.editor.ensureCursorVisible() + self._update_highlight() + + def find_prev(self): + txt = self.edit.text() + if not txt: + return + # If current selection == query, bump caret to the start so we don't re-match it. + c = self.editor.textCursor() + if c.hasSelection(): + sel = c.selectedText() + same = ( + (sel == txt) + if self.case.isChecked() + else (sel.casefold() == txt.casefold()) + ) + if same: + start = min(c.position(), c.anchor()) + c.setPosition(start, QTextCursor.MoveAnchor) + self.editor.setTextCursor(c) + if not self.editor.find(txt, self._flags(True)): + cur = self.editor.textCursor() + cur.movePosition(QTextCursor.End) + self.editor.setTextCursor(cur) + self.editor.find(txt, self._flags(True)) + self.editor.ensureCursorVisible() + self._update_highlight() + + def _update_highlight(self): + txt = self.edit.text() + if not txt: + self._clear_highlight() + return + + doc = self.editor.document() + flags = self._flags(False) + cur = QTextCursor(doc) + cur.movePosition(QTextCursor.Start) + + fmt = QTextCharFormat() + hl = self.palette().highlight().color() + hl.setAlpha(90) + fmt.setBackground(hl) + + selections = [] + while True: + cur = doc.find(txt, cur, flags) + if cur.isNull(): + break + sel = QTextEdit.ExtraSelection() + sel.cursor = cur + sel.format = fmt + selections.append(sel) + + self.editor.setExtraSelections(selections) + + def _clear_highlight(self): + self.editor.setExtraSelections([]) diff --git a/bouquin/main_window.py b/bouquin/main_window.py index d00e013..27934d4 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -24,6 +24,7 @@ from PySide6.QtGui import ( QDesktopServices, QFont, QGuiApplication, + QKeySequence, QPalette, QTextCharFormat, QTextCursor, @@ -44,6 +45,7 @@ from PySide6.QtWidgets import ( from .db import DBManager from .editor import Editor +from .find_bar import FindBar from .history_dialog import HistoryDialog from .key_prompt import KeyPrompt from .lock_overlay import LockOverlay @@ -159,6 +161,11 @@ 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) + self.statusBar().addPermanentWidget(self.findBar) + # When the findBar closes, put the caret back in the editor + self.findBar.closed.connect(self._focus_editor_now) # Menu bar (File) mb = self.menuBar() @@ -213,6 +220,24 @@ class MainWindow(QMainWindow): nav_menu.addAction(act_today) self.addAction(act_today) + act_find = QAction("Find on page", self) + act_find.setShortcut(QKeySequence.Find) + act_find.triggered.connect(self.findBar.show_bar) + nav_menu.addAction(act_find) + self.addAction(act_find) + + act_find_next = QAction("Find Next", self) + act_find_next.setShortcut(QKeySequence.FindNext) + act_find_next.triggered.connect(self.findBar.find_next) + nav_menu.addAction(act_find_next) + self.addAction(act_find_next) + + act_find_prev = QAction("Find Previous", self) + act_find_prev.setShortcut(QKeySequence.FindPrevious) + act_find_prev.triggered.connect(self.findBar.find_prev) + nav_menu.addAction(act_find_prev) + self.addAction(act_find_prev) + # Help menu with drop-down help_menu = mb.addMenu("&Help") act_docs = QAction("Documentation", self) @@ -977,3 +1002,7 @@ If you want an encrypted backup, choose Backup instead of Export. ) cur.setPosition(old_pos, mode) ed.setTextCursor(cur) + + # Refresh highlights if the theme changed + if hasattr(self, "findBar"): + self.findBar.refresh() diff --git a/pyproject.toml b/pyproject.toml index a8cf950..8a41b6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bouquin" -version = "0.1.11" +version = "0.1.12" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel Jacq "] readme = "README.md" diff --git a/tests/test_find_bar.py b/tests/test_find_bar.py new file mode 100644 index 0000000..6c10982 --- /dev/null +++ b/tests/test_find_bar.py @@ -0,0 +1,100 @@ +from PySide6.QtCore import Qt +from PySide6.QtGui import QKeySequence, QTextCursor +from PySide6.QtTest import QTest + +from tests.qt_helpers import trigger_menu_action + + +def _cursor_info(editor): + """Return (start, end, selectedText) for the current selection.""" + tc: QTextCursor = editor.textCursor() + start = min(tc.anchor(), tc.position()) + end = max(tc.anchor(), tc.position()) + return start, end, tc.selectedText() + + +def test_find_actions_and_shortcuts(open_window, qtbot): + win = open_window + + # Actions should be present under Navigate and advertise canonical shortcuts + act_find = trigger_menu_action(win, "Find on page") + assert act_find.shortcut().matches(QKeySequence.Find) == QKeySequence.ExactMatch + + act_next = trigger_menu_action(win, "Find Next") + assert act_next.shortcut().matches(QKeySequence.FindNext) == QKeySequence.ExactMatch + + act_prev = trigger_menu_action(win, "Find Previous") + assert ( + act_prev.shortcut().matches(QKeySequence.FindPrevious) + == QKeySequence.ExactMatch + ) + + # "Find on page" should open the bar and focus the input + act_find.trigger() + qtbot.waitUntil(lambda: win.findBar.isVisible()) + qtbot.waitUntil(lambda: win.findBar.edit.hasFocus()) + + +def test_find_navigate_case_sensitive_and_close_focus(open_window, qtbot): + win = open_window + + # Mixed-case content with three matches + text = "alpha … ALPHA … alpha" + win.editor.setPlainText(text) + qtbot.waitUntil(lambda: win.editor.toPlainText() == text) + + # Open the find bar from the menu + trigger_menu_action(win, "Find on page").trigger() + qtbot.waitUntil(lambda: win.findBar.isVisible()) + win.findBar.edit.clear() + QTest.keyClicks(win.findBar.edit, "alpha") + + # 1) First hit (case-insensitive default) + QTest.keyClick(win.findBar.edit, Qt.Key_Return) + qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection()) + s0, e0, sel0 = _cursor_info(win.editor) + assert sel0.lower() == "alpha" + + # 2) Next → uppercase ALPHA (case-insensitive) + trigger_menu_action(win, "Find Next").trigger() + qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection()) + s1, e1, sel1 = _cursor_info(win.editor) + assert sel1.upper() == "ALPHA" + + # 3) Next → the *other* lowercase "alpha" + trigger_menu_action(win, "Find Next").trigger() + qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection()) + s2, e2, sel2 = _cursor_info(win.editor) + assert sel2.lower() == "alpha" + # Ensure we didn't wrap back to the very first "alpha" + assert s2 != s0 + + # 4) Case-sensitive: skip ALPHA and only hit lowercase + win.findBar.case.setChecked(True) + # Put the caret at start to make the next search deterministic + tc = win.editor.textCursor() + tc.setPosition(0) + win.editor.setTextCursor(tc) + + win.findBar.find_next() + qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection()) + s_cs1, e_cs1, sel_cs1 = _cursor_info(win.editor) + assert sel_cs1 == "alpha" + + win.findBar.find_next() + qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection()) + s_cs2, e_cs2, sel_cs2 = _cursor_info(win.editor) + assert sel_cs2 == "alpha" + assert s_cs2 != s_cs1 # it's the other lowercase match + + # 5) Previous goes back to the earlier lowercase match + win.findBar.find_prev() + qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection()) + s_prev, e_prev, sel_prev = _cursor_info(win.editor) + assert sel_prev == "alpha" + assert s_prev == s_cs1 + + # 6) Close returns focus to editor + win.findBar.closeBtn.click() + qtbot.waitUntil(lambda: not win.findBar.isVisible()) + qtbot.waitUntil(lambda: win.editor.hasFocus())