pax 5261fa176d add search history setting
New setting "Record recent searches" (on by default). When disabled,
searches are not recorded and the Recent section is hidden from the
history dropdown. Saved searches are unaffected.

behavior change: opt-in setting, on by default (preserves existing behavior)
2026-04-10 16:28:43 -05:00

205 lines
7.1 KiB
Python

"""Search bar with tag autocomplete, history, and saved searches."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal, QTimer, QStringListModel
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import (
QWidget,
QHBoxLayout,
QLineEdit,
QPushButton,
QCompleter,
QMenu,
QInputDialog,
)
from ..core.db import Database
class SearchBar(QWidget):
"""Tag search bar with autocomplete, history dropdown, and saved searches."""
search_requested = Signal(str)
autocomplete_requested = Signal(str)
def __init__(self, db: Database | None = None, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._db = db
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(6)
self._input = QLineEdit()
self._input.setPlaceholderText("Search tags...")
self._input.returnPressed.connect(self._do_search)
# Dropdown arrow inside search bar
from PySide6.QtGui import QPixmap, QPainter, QFont
pixmap = QPixmap(16, 16)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setPen(self._input.palette().color(self._input.palette().ColorRole.Text))
painter.setFont(QFont(self._input.font().family(), 8))
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "\u25BC")
painter.end()
self._history_action = self._input.addAction(
QIcon(pixmap),
QLineEdit.ActionPosition.TrailingPosition,
)
self._history_action.setToolTip("Search history & saved searches")
self._history_action.triggered.connect(self._show_history_menu)
layout.addWidget(self._input, stretch=1)
# Save search button
self._save_btn = QPushButton("Save")
self._save_btn.setFixedWidth(60)
self._save_btn.setToolTip("Save current search")
self._save_btn.clicked.connect(self._save_current_search)
layout.addWidget(self._save_btn)
self._btn = QPushButton("Search")
self._btn.clicked.connect(self._do_search)
layout.addWidget(self._btn)
# Autocomplete
self._completer_model = QStringListModel()
self._completer = QCompleter(self._completer_model)
self._completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
self._completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
self._input.setCompleter(self._completer)
# Debounce
self._ac_timer = QTimer()
self._ac_timer.setSingleShot(True)
self._ac_timer.setInterval(300)
self._ac_timer.timeout.connect(self._request_autocomplete)
self._input.textChanged.connect(self._on_text_changed)
def _on_text_changed(self, text: str) -> None:
self._ac_timer.start()
def _request_autocomplete(self) -> None:
text = self._input.text().strip()
if not text:
return
last_tag = text.split()[-1] if text.split() else ""
query = last_tag.lstrip("-")
if len(query) >= 2:
self.autocomplete_requested.emit(query)
def set_suggestions(self, suggestions: list[str]) -> None:
self._completer_model.setStringList(suggestions)
def _do_search(self) -> None:
query = self._input.text().strip()
if self._db and query and self._db.get_setting_bool("search_history_enabled"):
self._db.add_search_history(query)
self.search_requested.emit(query)
def _show_history_menu(self) -> None:
if not self._db:
return
menu = QMenu(self)
saved_actions = {}
hist_actions = {}
# Saved searches
saved = self._db.get_saved_searches()
if saved:
saved_header = menu.addAction("-- Saved Searches --")
saved_header.setEnabled(False)
for sid, name, query in saved:
a = menu.addAction(f" {name} ({query})")
saved_actions[id(a)] = (sid, query)
menu.addSeparator()
# History (only shown when the setting is on)
history = self._db.get_search_history() if self._db.get_setting_bool("search_history_enabled") else []
if history:
hist_header = menu.addAction("-- Recent --")
hist_header.setEnabled(False)
for query in history:
a = menu.addAction(f" {query}")
hist_actions[id(a)] = query
menu.addSeparator()
clear_action = menu.addAction("Clear History")
else:
clear_action = None
# Management actions
delete_saved = None
if saved:
delete_saved = menu.addAction("Manage Saved Searches...")
menu.addSeparator()
if not saved and not history:
empty = menu.addAction("No history yet")
empty.setEnabled(False)
action = menu.exec(self._input.mapToGlobal(self._input.rect().bottomLeft()))
if not action:
return
if clear_action and action == clear_action:
self._db.clear_search_history()
elif delete_saved and action == delete_saved:
self._delete_saved_search_dialog()
elif id(action) in hist_actions:
self._input.setText(hist_actions[id(action)])
self._do_search()
elif id(action) in saved_actions:
_, query = saved_actions[id(action)]
self._input.setText(query)
self._do_search()
def _delete_saved_search_dialog(self) -> None:
from PySide6.QtWidgets import QListWidget, QDialog, QVBoxLayout, QDialogButtonBox
saved = self._db.get_saved_searches()
if not saved:
return
dlg = QDialog(self)
dlg.setWindowTitle("Delete Saved Searches")
dlg.setMinimumWidth(300)
layout = QVBoxLayout(dlg)
lst = QListWidget()
for sid, name, query in saved:
lst.addItem(f"{name} ({query})")
layout.addWidget(lst)
btns = QDialogButtonBox()
delete_btn = btns.addButton("Delete Selected", QDialogButtonBox.ButtonRole.DestructiveRole)
btns.addButton(QDialogButtonBox.StandardButton.Close)
btns.rejected.connect(dlg.reject)
layout.addWidget(btns)
def _delete():
row = lst.currentRow()
if 0 <= row < len(saved):
self._db.remove_saved_search(saved[row][0])
lst.takeItem(row)
saved.pop(row)
delete_btn.clicked.connect(_delete)
dlg.exec()
def _save_current_search(self) -> None:
if not self._db:
return
query = self._input.text().strip()
if not query:
return
name, ok = QInputDialog.getText(self, "Save Search", "Name:", text=query)
if ok and name.strip():
self._db.add_saved_search(name.strip(), query)
def text(self) -> str:
return self._input.text().strip()
def set_text(self, text: str) -> None:
self._input.setText(text)
def focus(self) -> None:
self._input.setFocus()