"""Suggest field names and values while the user types a search query."""
from __future__ import annotations
import re
from collections.abc import Callable, Sequence
from PySide6.QtCore import QObject, QStringListModel, Qt
from PySide6.QtWidgets import QCompleter, QLineEdit
from ..pdf.search import SEARCH_FIELDS, resolve_field
# Fields whose stored values are useful suggestions.
VALUE_FIELDS = frozenset({"author", "first_author", "journal", "year"})
# The token before the cursor: an optional field name, then the value so far.
_TOKEN = re.compile(r'(?:(?P<field>[A-Za-z_]+):)?(?P<quote>")?(?P<value>[^"]*)$')
[docs]
class SearchCompleter(QObject):
"""Show a popup of completions for the term under the cursor.
A word without a colon gets field-name suggestions: ``f`` suggests
``first_author:`` and ``filename:``. After ``author:``, ``first_author:``,
``journal:``, or ``year:``, `values` supplies the suggestions. Choosing
a suggestion replaces only that term.
Parameters
----------
line_edit : QLineEdit
Search field.
values : Callable[[str], Sequence[str]]
Return the stored values of one field key, for example author names.
"""
def __init__(
self, line_edit: QLineEdit, values: Callable[[str], Sequence[str]]
) -> None:
super().__init__(line_edit)
self._line_edit = line_edit
self._values = values
self._model = QStringListModel(self)
self.completer = QCompleter(self._model, self)
self.completer.setWidget(line_edit)
self.completer.setCaseSensitivity(Qt.CaseInsensitive)
self.completer.setCompletionMode(QCompleter.PopupCompletion)
self.completer.setFilterMode(Qt.MatchStartsWith)
self.completer.setMaxVisibleItems(12)
self.completer.activated.connect(self._insert)
line_edit.textEdited.connect(self._update)
self._token_start = 0
self._field: str | None = None
def _current_token(self) -> tuple[int, str]:
"""Return the start index and text of the term before the cursor.
A space ends a term, except inside double quotes.
"""
text = self._line_edit.text()[: self._line_edit.cursorPosition()]
start = 0
in_quote = False
for index, char in enumerate(text):
if char == '"':
in_quote = not in_quote
elif char.isspace() and not in_quote:
start = index + 1
return start, text[start:]
def _update(self, _text: str = "") -> None:
"""Offer completions for the term before the cursor."""
start, token = self._current_token()
match = _TOKEN.match(token)
if not token or match is None:
self.completer.popup().hide()
return
self._token_start = start
field_name = match.group("field")
if field_name is None:
self._field = None
prefix = token
suggestions = [f"{name}:" for name in SEARCH_FIELDS]
else:
field = resolve_field(field_name)
if field not in VALUE_FIELDS:
self.completer.popup().hide()
return
self._field = field
prefix = match.group("value")
suggestions = list(self._values(field))
if not prefix and field_name is None:
self.completer.popup().hide()
return
self._model.setStringList(suggestions)
self.completer.setCompletionPrefix(prefix)
if self.completer.completionCount() == 0 or (
self.completer.completionCount() == 1
and self.completer.currentCompletion().casefold() == prefix.casefold()
):
self.completer.popup().hide()
return
self.completer.complete()
def _insert(self, completion: str) -> None:
"""Replace the term before the cursor with `completion`."""
text = self._line_edit.text()
cursor = self._line_edit.cursorPosition()
if self._field is None:
replacement = completion
else:
value = f'"{completion}"' if re.search(r'[\s"]', completion) else completion
replacement = f"{self._field}:{value} "
updated = text[: self._token_start] + replacement + text[cursor:]
self._line_edit.setText(updated)
self._line_edit.setCursorPosition(self._token_start + len(replacement))