Add find bar for searching for text in the editor
This commit is contained in:
parent
cf594487bc
commit
5489854d58
5 changed files with 320 additions and 1 deletions
|
|
@ -1,3 +1,7 @@
|
||||||
|
# 0.1.12
|
||||||
|
|
||||||
|
* Add find bar for searching for text in the editor
|
||||||
|
|
||||||
# 0.1.11
|
# 0.1.11
|
||||||
|
|
||||||
* Add missing export extensions to export_by_extension
|
* Add missing export extensions to export_by_extension
|
||||||
|
|
|
||||||
186
bouquin/find_bar.py
Normal file
186
bouquin/find_bar.py
Normal file
|
|
@ -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([])
|
||||||
|
|
@ -24,6 +24,7 @@ from PySide6.QtGui import (
|
||||||
QDesktopServices,
|
QDesktopServices,
|
||||||
QFont,
|
QFont,
|
||||||
QGuiApplication,
|
QGuiApplication,
|
||||||
|
QKeySequence,
|
||||||
QPalette,
|
QPalette,
|
||||||
QTextCharFormat,
|
QTextCharFormat,
|
||||||
QTextCursor,
|
QTextCursor,
|
||||||
|
|
@ -44,6 +45,7 @@ from PySide6.QtWidgets import (
|
||||||
|
|
||||||
from .db import DBManager
|
from .db import DBManager
|
||||||
from .editor import Editor
|
from .editor import Editor
|
||||||
|
from .find_bar import FindBar
|
||||||
from .history_dialog import HistoryDialog
|
from .history_dialog import HistoryDialog
|
||||||
from .key_prompt import KeyPrompt
|
from .key_prompt import KeyPrompt
|
||||||
from .lock_overlay import LockOverlay
|
from .lock_overlay import LockOverlay
|
||||||
|
|
@ -159,6 +161,11 @@ 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
|
||||||
|
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)
|
# Menu bar (File)
|
||||||
mb = self.menuBar()
|
mb = self.menuBar()
|
||||||
|
|
@ -213,6 +220,24 @@ class MainWindow(QMainWindow):
|
||||||
nav_menu.addAction(act_today)
|
nav_menu.addAction(act_today)
|
||||||
self.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 with drop-down
|
||||||
help_menu = mb.addMenu("&Help")
|
help_menu = mb.addMenu("&Help")
|
||||||
act_docs = QAction("Documentation", self)
|
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)
|
cur.setPosition(old_pos, mode)
|
||||||
ed.setTextCursor(cur)
|
ed.setTextCursor(cur)
|
||||||
|
|
||||||
|
# Refresh highlights if the theme changed
|
||||||
|
if hasattr(self, "findBar"):
|
||||||
|
self.findBar.refresh()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "bouquin"
|
name = "bouquin"
|
||||||
version = "0.1.11"
|
version = "0.1.12"
|
||||||
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"
|
||||||
|
|
|
||||||
100
tests/test_find_bar.py
Normal file
100
tests/test_find_bar.py
Normal file
|
|
@ -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())
|
||||||
Loading…
Add table
Add a link
Reference in a new issue