Source code for spacr.qt.help_search

"""The search field beside the Help menu, and what each result opens.

:mod:`spacr.qt.help_index` decides WHAT is addressable by
name; this module is where a user types a name and where the answer takes
them. The two halves are apart because only this one needs a display.

WHAT A RESULT DOES IS REGISTERED, NOT SWITCHED ON. :func:`register_opener`
binds a kind to ``(window, entry) -> str``; :func:`open_entry` looks the kind
up. A new kind of thing therefore needs a provider in the index module and an
opener here, and no existing function changes -- which is the instruction's
"and so on" clause taken literally.

The four that ship:

``module``
    :meth:`spacr.qt.app.MainWindow.open_module`, which resolves a folded
    module to the host that took it over.

``setting``
    Opens the module and then :func:`reveal_setting`: every category
    collapsed except the one holding the setting, the row scrolled to and
    marked. NOT by filtering the panel -- the per-module strip already has a
    filter and it leaves the other categories hidden, so a user who wanted to
    look around next has to work out what happened to the form.

``preference``
    Opens Preferences on the tab that holds the row and marks the row.

``api``
    Opens the published page WHEN IT IS REACHABLE, and otherwise shows the
    docstring that is on this machine and says the page is not reachable.
    A search result is believed, so a result that opens a 404 is worse than
    no result at all.

NOTHING THIS MODULE DOES ON THE GUI THREAD BLOCKS IT. The index costs about a
second to build, so it is built on a worker the first time the field is typed
in (:class:`_IndexLoader`), and the field says so until it lands. Whether the
documentation site answers is a network question, so it is asked on a worker
too (:class:`_DocsReach`) and only ever when the user has opened an API
result -- spaCR does not reach the network because somebody typed.

OPENING A RESULT NEVER DISCARDS SETTINGS. ``MainWindow._on_nav_selected``
keeps every screen it has built in ``self._screens`` and switches the stack to
it, so a half-filled Mask form is still half-filled when the user comes back
from Measure. That was true before this field existed and it is now pinned by
``tests/qt/test_the_help_search_field_lands_where_it_says.py`` so that a
future rebuild-on-navigate cannot quietly take it away.
"""
from __future__ import annotations

import logging
import threading
from functools import partial
from typing import Callable, Dict, List, Optional

from PySide6.QtCore import QEvent, QObject, Qt, QTimer, Signal
from PySide6.QtGui import QKeyEvent
from PySide6.QtWidgets import (
    QDialog,
    QDialogButtonBox,
    QFrame,
    QHBoxLayout,
    QLabel,
    QLayout,
    QLineEdit,
    QListWidget,
    QListWidgetItem,
    QMainWindow,
    QTextBrowser,
    QVBoxLayout,
    QWidget,
)

from .help_index import (
    PER_KIND_LIMIT,
    HelpEntry,
    build_index,
    rendered_description,
    rendered_subtitle,
    _search_with_overflow,
)
from .i18n import tr

LOG = logging.getLogger("spacr.qt.help_search")

#: objectNames, so the theme can reach these and tests can find them.
FIELD_NAME = "HelpSearchField"
POPUP_NAME = "HelpSearchResults"
LIST_NAME = "HelpSearchResultList"
NOTE_NAME = "HelpSearchNote"
DIALOG_NAME = "HelpSearchApiEntry"

#: How long the field waits after a keystroke before searching, which is what
#: turns eight searches into one while a word is typed.
#:
#: WHAT ONE SEARCH COSTS, measured over the 11,248-row index: 16-24 ms for a
#: word or a phrase, and 34 ms for a single letter -- a one- or two-letter
#: term is below the fuzzy band's floor and is matched by substring against
#: every row, so the SHORTEST query is the dearest one. That is one to two
#: frames on the GUI thread, not the sixth of a frame an earlier note here
#: claimed by quoting the best case; and it is paid on the first letter of
#: every query, which is the letter it is least affordable on. A pause of
#: 140 ms is still invisible to a typist, and the debounce is what keeps the
#: cost to one search per pause rather than one per letter.
DEBOUNCE_MS = 140

#: How many rows the popup shows.
RESULT_LIMIT = 24

#: Role the entry is stashed under on its list item.
ENTRY_ROLE = int(Qt.UserRole) + 1

#: Role an "and N more" row stashes its kind under. A separate role, not a
#: fake :class:`HelpEntry`: nothing that opens entries may ever be handed one.
MORE_ROLE = int(Qt.UserRole) + 2

#: How many more rows of a kind opening its "and N more" row reveals. One
#: step answers every setting in this tree (``src`` is the widest, 28 left
#: out); the API kind can leave thousands out for a short query, and drawing
#: them all at once would put a ten-thousand-row list on the GUI thread, so
#: it pages instead and the row counts down.
MORE_STEP = 40


def _localize(widget: QWidget, setter_name: str, property_name: str,
              text: str, **kwargs) -> None:
    """Set a caption now and leave behind what a language change re-reads.

    The same seam :mod:`spacr.qt.settings_search` uses, and it has to be the
    same in both halves. :func:`spacr.qt.i18n.retranslate_widget_tree` reads
    a widget's English back out of two QT PROPERTIES -- the source it should
    translate, and the rendering it last put on screen -- through
    ``obj.property(...)``. A plain Python attribute is invisible to that
    call: the next language pass would find no source, adopt whatever is on
    screen as the canonical English, and translate a translation. This field
    is built from ``stack.currentChanged``, after the window's one language
    pass, so it is created already rendered in the user's language and the
    mistake is not hypothetical -- it would freeze the placeholder, the
    tooltip, the accessible name and the API dialog's note in whichever
    language happened to be current when the strip was first built.

    Writing the rendering as well is what tells the translator that the
    caption is still static chrome rather than live data a handler wrote.

    :param widget: the widget to caption.
    :param setter_name: the setter to call, e.g. ``"setToolTip"``.
    :param property_name: the i18n source property the setter's translator
        reads, e.g. ``"_spacr_i18n_tooltip"``.
    :param text: the English source string.
    :param kwargs: placeholders for :func:`spacr.qt.i18n.tr`.
    """
    setter = getattr(widget, setter_name, None)
    if setter is None:
        return
    try:
        rendered = tr(text, **kwargs) if kwargs else tr(text)
    except Exception:
        rendered = text
    try:
        widget.setProperty(property_name, text)
        widget.setProperty(f"{property_name}_last_rendered", rendered)
    except (AttributeError, RuntimeError, TypeError):
        LOG.debug("could not remember the English behind %r", text)
    setter(rendered)


_OPENERS: Dict[str, Callable[[QMainWindow, HelpEntry], str]] = {}


[docs] def register_opener(kind: str, opener: Callable[[QMainWindow, HelpEntry], str]) -> None: """Say what happens when a result of ``kind`` is chosen. :param kind: the entry kind, matching a provider in :mod:`spacr.qt.help_index`. :param opener: ``(window, entry) -> str``; the string is shown in the status bar. """ _OPENERS[str(kind)] = opener
[docs] def opener_kinds() -> tuple: """The kinds that can be opened, in registration order.""" return tuple(_OPENERS)
[docs] def open_entry(window: QMainWindow, entry: HelpEntry) -> str: """Take the user to ``entry``. :param window: the main window. :param entry: the chosen result. :returns: what to say in the status bar; ``""`` when nothing was done. """ opener = _OPENERS.get(entry.kind) if opener is None: LOG.debug("no opener registered for %r", entry.kind) return "" try: return str(opener(window, entry) or "") except Exception: LOG.exception("could not open the %s result %r", entry.kind, entry.title) return tr("Could not open {name}.", name=entry.title)
[docs] def reveal_setting(window: QMainWindow, app_key: str, key: str) -> bool: """Open ``app_key`` with only ``key``'s category expanded and ``key`` shown. Goes through the per-module search strip, which already owns the map from a setting key to the section and field widget rendering it. Building a second map here would be a second thing to keep in step with the form. :param window: the main window. :param app_key: the module to open. :param key: the setting to reveal. :returns: True when the row was found and revealed. """ opened = app_key try: opened = window.open_module(app_key) except Exception: LOG.exception("could not open %r", app_key) return False screen = getattr(window, "_screens", {}).get(opened) if screen is None: return False bar = getattr(screen, "_settings_search", None) reveal = getattr(bar, "reveal", None) if not callable(reveal): return False try: return bool(reveal(key)) except Exception: LOG.exception("could not reveal %r on %r", key, opened) return False
def _open_module(window: QMainWindow, entry: HelpEntry) -> str: """Opener for ``kind="module"``.""" key = entry.payload.get("app", "") window.open_module(key) return tr("Opened {name}", name=entry.title) def _open_setting(window: QMainWindow, entry: HelpEntry) -> str: """Opener for ``kind="setting"``.""" app_key = entry.payload.get("app", "") key = entry.payload.get("key", "") if reveal_setting(window, app_key, key): return tr("{key} in {where}", key=key, where=rendered_subtitle(entry, tr)) return tr("{key} is not on this module's form.", key=key) def _open_preference(window: QMainWindow, entry: HelpEntry) -> str: """Opener for ``kind="preference"``. THE ANSWER IS WHAT HAPPENED, not what was attempted. A preference tab can be gone by the time a row built at the start of the session is chosen -- the Fractal page exists only while that backdrop is on -- and ``show_preferences_on`` says so by returning False. Reporting "in Preferences ▸ Fractal" over a dialog that opened on some other page is the search telling the user something it did not do. """ tab = entry.payload.get("tab", "") label = entry.payload.get("label", entry.title) show = getattr(window, "show_preferences_on", None) if not callable(show): return tr("Could not open {name}.", name=label) landed = show(tab, label) if landed is False: return tr("{name} is not in Preferences at the moment.", name=label) return tr("{name} in {where}", name=label, where=rendered_subtitle(entry, tr)) def _open_api(window: QMainWindow, entry: HelpEntry) -> str: """Opener for ``kind="api"``: the page when it answers, the docstring when it does not.""" symbol = entry.payload.get("symbol", entry.title) url = api_url(symbol) state = docs_reach().state() if state == "reachable": try: import webbrowser webbrowser.open(url) except Exception: LOG.exception("could not open %s", url) return tr("Could not open {url}", url=url) return tr("Opened {name} in the browser", name=symbol) docs_reach().start() show_api_entry(window, symbol, url, entry.description) return str(symbol) register_opener("module", _open_module) register_opener("setting", _open_setting) register_opener("preference", _open_preference) register_opener("api", _open_api)
[docs] def api_url(symbol: str, language: Optional[str] = None) -> str: """The published page for a dotted symbol. The same shape ``spacr.qt.screens.settings_model.api_docs_url`` builds for a settings row -- ``<base>/spacr/core/index.html#spacr.core.f``, with ``?lang=`` on a page the reader wants in another language -- from the same ``DOCS_API_BASE``, so the search field and a settings row cannot disagree about where the documentation lives. WHICH PREFIX IS THE MODULE is answered from the disk rather than assumed: ``spacr.qt.screens.mask`` is a module and ``spacr.layers.ShapesLayer.mask`` is an attribute two levels inside one, and only the file layout knows which of the dots is the last one in the path. :param symbol: a dotted symbol name. :param language: a language code; the current one when ``None``. :returns: an absolute URL. """ from .screens.settings_model import DOCS_API_BASE, _language_code parts = str(symbol).split(".") url = f"{DOCS_API_BASE}/index.html" for cut in range(len(parts), 0, -1): if _module_file(".".join(parts[:cut])) is None: continue page = f"{DOCS_API_BASE}/{'/'.join(parts[:cut])}/index.html" url = page if cut == len(parts) else f"{page}#{'.'.join(parts)}" break try: code = _language_code(language) except Exception: code = "en" if code == "en": return url base, _, fragment = url.partition("#") return f"{base}?lang={code}" + (f"#{fragment}" if fragment else "")
def _module_file(dotted: str): """The source file of ``dotted`` without importing it. An import would run module-level code for ten thousand candidate prefixes; the package layout answers the same question from the disk. :param dotted: a dotted module name inside ``spacr``. :returns: a :class:`pathlib.Path`, or ``None``. """ from pathlib import Path import spacr parts = str(dotted).split(".") if not parts or parts[0] != "spacr": return None root = Path(spacr.__file__).resolve().parent rest = parts[1:] base = root.joinpath(*rest) if rest else root for candidate in (base.with_suffix(".py"), base / "__init__.py"): try: if candidate.is_file(): return candidate except OSError: return None return None
[docs] def local_docstring(symbol: str) -> str: """The docstring of ``symbol`` as it is on this machine. Read from the source with :mod:`ast` rather than by importing: the offline path must not be the one that drags ``spacr.core`` and its scientific stack into the process, and a docstring is in the syntax tree. :param symbol: a dotted symbol name. :returns: the docstring, or ``""`` when it cannot be found. """ import ast parts = str(symbol).split(".") for cut in range(len(parts), 0, -1): path = _module_file(".".join(parts[:cut])) if path is None: continue try: tree = ast.parse(path.read_text(encoding="utf-8")) except Exception: return "" node = tree for name in parts[cut:]: found = None for child in getattr(node, "body", []): if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and child.name == name: found = child break if found is None: return "" node = found try: return ast.get_docstring(node) or "" except Exception: return "" return ""
class _DocsReach(QObject): """Whether the published documentation answers, asked off the GUI thread. Three states rather than two. ``"unknown"`` is the honest answer before anybody has asked, and it is treated as "do not claim the link works": the offline view opens, and its button to the web page becomes live if and when the probe says the site is there. A two-state version has to guess, and guessing "reachable" is how a search result becomes a 404. :ivar settled: emitted with the new state when the probe answers. """ settled = Signal(str) #: What the probe fetches, and how long it waits. Short: the answer is #: only used to decide between a browser and a dialog, and a user who #: waited five seconds for that has been let down either way. TIMEOUT_S = 4.0 def __init__(self) -> None: """Start unasked.""" super().__init__() self._state = "unknown" self._thread: Optional[threading.Thread] = None self._lock = threading.Lock() def state(self) -> str: """``"reachable"``, ``"unreachable"`` or ``"unknown"``.""" return self._state def set_state(self, state: str) -> None: """Record an answer, whoever found it out. Public so a test can state what the network is doing instead of depending on it. :param state: one of the three states. """ self._state = str(state) self.settled.emit(self._state) def start(self) -> None: """Ask, once, on a daemon thread; do nothing if already asked.""" with self._lock: if self._state != "unknown" or self._thread is not None: return self._thread = threading.Thread( target=self._probe, name="spacr-docs-reach", daemon=True) self._thread.start() def _probe(self) -> None: """Fetch the documentation root and record whether it answered.""" from .screens.settings_model import DOCS_SITE_BASE answer = "unreachable" try: from urllib.request import urlopen with urlopen(DOCS_SITE_BASE, timeout=self.TIMEOUT_S) as response: if int(getattr(response, "status", 200) or 200) < 400: answer = "reachable" except Exception: LOG.debug("the documentation site did not answer", exc_info=True) with self._lock: self._thread = None self.set_state(answer) _REACH: Optional[_DocsReach] = None
[docs] def docs_reach() -> _DocsReach: """The one reachability probe, made on first use.""" global _REACH if _REACH is None: _REACH = _DocsReach() return _REACH
[docs] class ApiEntryDialog(QDialog): """A symbol's local docstring, shown when its web page cannot be reached. :param symbol: the dotted symbol. :param url: where the page would be. :param fallback: the indexed summary, used when the source has no docstring to read. :param parent: parent widget. """ def __init__(self, symbol: str, url: str, fallback: str = "", parent: Optional[QWidget] = None): """Build the offline view of one API entry.""" super().__init__(parent) self.setObjectName(DIALOG_NAME) self.setWindowTitle(symbol) self._url = url column = QVBoxLayout(self) self._note = QLabel(self) self._note.setObjectName(NOTE_NAME) self._note.setWordWrap(True) column.addWidget(self._note) body = QTextBrowser(self) body.setOpenExternalLinks(False) body.setPlainText(local_docstring(symbol) or fallback or tr("No description is stored for this entry.")) column.addWidget(body, 1) address = QLabel(url, self) address.setWordWrap(True) address.setTextInteractionFlags(Qt.TextSelectableByMouse) column.addWidget(address) buttons = QDialogButtonBox(QDialogButtonBox.Close, parent=self) self._open = buttons.addButton( tr("Open the web page"), QDialogButtonBox.ActionRole) self._open.clicked.connect(self._open_the_page) buttons.rejected.connect(self.reject) column.addWidget(buttons) reach = docs_reach() reach.settled.connect(self._on_settled) self._on_settled(reach.state()) reach.start() def _on_settled(self, state: str) -> None: """Say what is known about the site, and enable the button if it is up. :param state: one of the three states of :class:`_DocsReach`. """ if state == "reachable": _localize(self._note, "setText", "_spacr_i18n_text", "This is the copy on this machine. The published page " "is reachable — open it for the rendered version.") elif state == "unreachable": _localize(self._note, "setText", "_spacr_i18n_text", "The published documentation is not reachable from " "here, so this is the description stored with the code " "on this machine.") else: _localize(self._note, "setText", "_spacr_i18n_text", "This is the description stored with the code on this " "machine. Checking whether the published page is " "reachable…") self._open.setEnabled(state == "reachable") def _open_the_page(self) -> None: """Open the published page in the system browser.""" import webbrowser try: webbrowser.open(self._url) except Exception: LOG.exception("could not open %s", self._url) self.accept()
[docs] def show_api_entry(window: Optional[QWidget], symbol: str, url: str, fallback: str = "") -> ApiEntryDialog: """Show one API entry's local docstring. :param window: parent widget. :param symbol: the dotted symbol. :param url: where the published page would be. :param fallback: the indexed summary. :returns: the dialog, already shown. """ dialog = ApiEntryDialog(symbol, url, fallback, window) dialog.setAttribute(Qt.WA_DeleteOnClose, True) dialog.resize(560, 420) dialog.show() return dialog
class _IndexLoader(QObject): """Builds the index on a daemon thread and hands it back on the GUI one. :ivar ready: emitted with the list of entries. """ ready = Signal(object) def __init__(self, parent: Optional[QObject] = None): """Start idle; :meth:`start` is what costs anything.""" super().__init__(parent) self._thread: Optional[threading.Thread] = None self._started = False def started(self) -> bool: """Whether the build has been asked for.""" return self._started def start(self) -> None: """Build the index once, off the GUI thread.""" if self._started: return self._started = True self._thread = threading.Thread( target=self._work, name="spacr-help-index", daemon=True) self._thread.start() def _work(self) -> None: """Build the index and emit it; an empty list on failure.""" try: entries = build_index() except Exception: LOG.exception("the help index could not be built") entries = [] self.ready.emit(entries)
[docs] class HelpSearchField(QLineEdit): """The box beside the Help menu. Owns the popup, the index and the keyboard. Arrow keys and Return are forwarded to the list while it is open, which is what lets the whole feature be used without the mouse leaving the keyboard: focus, type, Down, Return. :param window: the main window results act on. :param parent: parent widget. """ #: Emitted after a result has been opened, with the entry. opened = Signal(object) def __init__(self, window: QMainWindow, parent: Optional[QWidget] = None): """Build the field, its popup and the debounce timer.""" super().__init__(parent) self.setObjectName(FIELD_NAME) self.setClearButtonEnabled(True) self._window = window self._index: Optional[List[HelpEntry]] = None self._results: List[HelpEntry] = [] #: Per-kind caps the user has lifted with an "and N more" row. They #: belong to one query and are dropped when the text changes. self._caps: Dict[str, int] = {} #: Kind -> matches left out of the list for the current query. self._left_out: Dict[str, int] = {} _localize(self, "setPlaceholderText", "_spacr_i18n_placeholder", "Search spaCR…") _localize(self, "setAccessibleName", "_spacr_i18n_accessible_name", "Search spaCR") self._popup = QFrame(window) self._popup.setObjectName(POPUP_NAME) self._popup.setFrameShape(QFrame.StyledPanel) self._popup.setAutoFillBackground(True) self._popup.setAttribute(Qt.WA_StyledBackground, True) self._popup.setFocusPolicy(Qt.NoFocus) self._popup.hide() popup_column = QVBoxLayout(self._popup) popup_column.setContentsMargins(0, 0, 0, 0) popup_column.setSpacing(0) popup_column.setSizeConstraint(QLayout.SetNoConstraint) self._note = QLabel(self._popup) self._note.setObjectName(NOTE_NAME) self._note.setWordWrap(True) self._note.setVisible(False) popup_column.addWidget(self._note) self._list = QListWidget(self._popup) self._list.setObjectName(LIST_NAME) self._list.setUniformItemSizes(False) self._list.setFocusPolicy(Qt.NoFocus) self._list.itemActivated.connect(self._on_activated) self._list.itemClicked.connect(self._on_activated) popup_column.addWidget(self._list, 1) self._debounce = QTimer(self) self._debounce.setSingleShot(True) self._debounce.setInterval(DEBOUNCE_MS) self._debounce.timeout.connect(self._refresh) self._loader = _IndexLoader(self) self._loader.ready.connect(self._on_index_ready) self.textEdited.connect(self._on_text_edited) self.returnPressed.connect(self._activate_current)
[docs] def set_index(self, entries: List[HelpEntry]) -> None: """Use ``entries`` instead of building one. :param entries: what :func:`spacr.qt.help_index.build_index` returned. """ self._index = list(entries) if self.text().strip(): self._refresh()
[docs] def index(self) -> Optional[List[HelpEntry]]: """The index, or ``None`` while it is still being built.""" return self._index
[docs] def results(self) -> List[HelpEntry]: """What the popup is showing, best first.""" return list(self._results)
[docs] def popup(self) -> QFrame: """The results popup, so a test can ask whether it is up.""" return self._popup
[docs] def note(self) -> str: """The line above the list: what is being built, or what matched.""" return self._note.text()
def _on_text_edited(self, text: str) -> None: """Start the index if it is wanted, and debounce the search. :param text: the new contents of the box. """ self._caps = {} if str(text).strip() and self._index is None: self._loader.start() self._show_note(tr("Building the index…")) self._debounce.start() def _on_index_ready(self, entries) -> None: """Take the index the worker built and search with it. :param entries: the list of entries, possibly empty. """ self._index = list(entries or []) if self.text().strip(): self._refresh() def _show_note(self, text: str) -> None: """Show ``text`` above the list and make sure the popup is up. :param text: the line to show. """ self._note.setText(text) self._note.setVisible(bool(text)) self._place_popup() def _refresh(self) -> None: """Recompute the results for whatever is in the box.""" query = self.text().strip() if not query: self._results = [] self._left_out = {} self._list.clear() self.hide_popup() return if self._index is None: self._show_note(tr("Building the index…")) return limit = RESULT_LIMIT + sum( cap - PER_KIND_LIMIT for cap in self._caps.values()) self._results, self._left_out = _search_with_overflow( self._index, query, limit=limit, caps=self._caps) self._list.clear() last_of_kind = {entry.kind: i for i, entry in enumerate(self._results)} for i, entry in enumerate(self._results): item = QListWidgetItem(self._row_text(entry)) item.setData(ENTRY_ROLE, entry) described = rendered_description(entry, tr) if described: item.setToolTip(described) self._list.addItem(item) if last_of_kind[entry.kind] == i and entry.kind in self._left_out: self._list.addItem(self._more_row(entry.kind)) if self._results: self._list.setCurrentRow(0) self._note.setVisible(False) self._note.setText("") else: self._show_note(tr("Nothing called “{query}”.", query=query)) self._place_popup() def _more_row(self, kind: str) -> QListWidgetItem: """The "and N more" row that follows the last shown row of ``kind``. It sits under its own kind rather than at the foot of the list, so it reads as "more of THESE" -- after the eight ``src`` rows, not after an API list that has nothing to do with them. :param kind: the kind with matches left out. :returns: an item carrying ``kind`` under :data:`MORE_ROLE`. """ item = QListWidgetItem( tr("and {count} more", count=self._left_out[kind])) item.setData(MORE_ROLE, kind) return item def _show_more(self, kind: str) -> None: """Lift the cap on ``kind`` by :data:`MORE_STEP` and search again. The selection lands on the first row the step revealed, which is where the "more" row was, so Return on "and 28 more" followed by Down walks into the new rows instead of back to the top. :param kind: the kind whose "more" row was opened. """ row = self._list.currentRow() self._caps[kind] = self._caps.get(kind, PER_KIND_LIMIT) + MORE_STEP self._refresh() if 0 <= row < self._list.count(): self._list.setCurrentRow(row) def _row_text(self, entry: HelpEntry) -> str: """One line for the list. The subtitle is rendered here rather than taken off the entry: the index is English because the query is matched against it, and it is built once a session while the language can change afterwards. Every keystroke redraws these rows, so rendering at this point is what makes the list follow a language change at all. :param entry: the result. :returns: the title, then where it lives, in the current language. """ where = rendered_subtitle(entry, tr) return f"{entry.title} {where}".rstrip() def _place_popup(self) -> None: """Size the list to the results and put it under the field. A CHILD OF THE WINDOW, not a ``Qt.Popup``. A popup window grabs the keyboard as soon as it is shown, so the next letter typed would go to the list instead of to the box the user is typing in -- and the list and the box are the same interaction. Staying inside the window keeps focus where the caret is, and both the frame and the list take ``NoFocus`` so that clicking a row does not move it either. The field is at the top of the window, so dropping the list down into the window costs nothing: there is always room below it. """ if not self.isVisible(): return window = self._popup.parentWidget() if window is None: return rows = min(max(self._list.count(), 1), 10) height = rows * max(self._list.sizeHintForRow(0), 18) + 8 if self._note.isVisible(): height += self._note.sizeHint().height() + 6 width = max(self.width() * 3, 360) corner = self.mapTo(window, self.rect().bottomLeft()) left = max(0, min(int(corner.x()), window.width() - int(width))) self._popup.setGeometry(left, int(corner.y()), int(width), int(height)) if not self._popup.isVisible(): self._popup.show() self._popup.raise_()
[docs] def hide_popup(self) -> None: """Put the popup away.""" self._popup.hide()
def _activate_current(self) -> None: """Open whatever row is selected.""" item = self._list.currentItem() if item is not None: self._on_activated(item) def _on_activated(self, item: QListWidgetItem) -> None: """Open the entry behind ``item`` and put the field away. An "and N more" row is not an entry: opening it keeps the popup up and reveals the next :data:`MORE_STEP` rows of its kind instead. :param item: the row the user chose. Activation is deferred because this runs inside the list's own click or activation signal, and _show_more clears the list, which would delete the item the view is still delivering. """ if item is None: return kind = item.data(MORE_ROLE) if kind: QTimer.singleShot(0, partial(self._show_more, str(kind))) return entry = item.data(ENTRY_ROLE) if entry is None: return self.hide_popup() message = open_entry(self._window, entry) if message: try: self._window.statusBar().showMessage(message, 4000) except Exception: LOG.debug("no status bar to report to", exc_info=True) self.opened.emit(entry)
[docs] def keyPressEvent(self, event: QKeyEvent) -> None: """Steer the list from the box, and let Esc give the window back. :param event: the key press. """ key = event.key() if key in (Qt.Key_Down, Qt.Key_Up, Qt.Key_PageDown, Qt.Key_PageUp): if self._list.count(): self._place_popup() self._list.keyPressEvent(event) return if key == Qt.Key_Escape: if self._popup.isVisible(): self.hide_popup() else: self.clear() self._window.setFocus() event.accept() return super().keyPressEvent(event)
[docs] def focusOutEvent(self, event) -> None: """Close the list when the caret leaves the box. Safe to do unconditionally now that the list takes ``NoFocus``: clicking a row cannot move focus, so a focus-out means the user went somewhere else and the list is in the way of whatever that was. :param event: the focus-out event; it is not inspected, only passed on to the base class after the list is hidden. """ self.hide_popup() super().focusOutEvent(event)
[docs] def field_of(window: QMainWindow) -> Optional[HelpSearchField]: """The field installed on ``window``, if there is one. :param window: the main window. :returns: the field, or ``None``. """ field = getattr(window, "_help_search", None) return field if isinstance(field, HelpSearchField) else None
[docs] def focus_field(window: QMainWindow) -> bool: """Put the caret in the help search box. :param window: the main window. :returns: True when there was a field to focus. """ field = field_of(window) if field is None: return False field.setFocus(Qt.ShortcutFocusReason) field.selectAll() return True
class _FieldPlacer(QObject): """Keep the search field immediately to the right of the last menu. WHY NOT A ``QWidgetAction`` ON THE MENU BAR, which is the one-line way to get a widget into the menu row: a widget inside a menu-bar action is driven as a menu item. On this window the field installed that way never takes focus (``focus_field`` left ``hasFocus()`` False) and the results popup never becomes visible, so Ctrl+Shift+H and the whole result list stop working. WHY NOT THE CORNER WIDGET, which is where the field started: the corner strip is right-aligned inside the bar, so the field sat against the minimise, full screen and close marks at the far edge of the window, away from the menus it belongs with. So the field is an ordinary child of the bar, moved to sit after the last menu. It is re-placed whenever the bar is resized, shown, or re-laid out after a language change, because the width of "Help" is not the same word in every language. :param bar: the menu bar the field belongs to. :param field: the search field. """ #: The gap between the last menu and the field, in device-independent px. GAP = 8 def __init__(self, bar, field) -> None: """Remember the menu bar and the field it places; owned by the bar.""" super().__init__(bar) self._bar = bar self._field = field def place(self) -> None: """Move the field to just after the last menu.""" bar, field = self._bar, self._field try: edges = [bar.actionGeometry(a).right() for a in bar.actions() if not a.isSeparator() and a.isVisible()] left = (max(edges) if edges else 0) + self.GAP height = min(field.sizeHint().height(), max(bar.height() - 6, 1)) top = max((bar.height() - height) // 2, 0) room = bar.width() - left - self._corner_width() - self.GAP width = max(min(field.maximumWidth(), room), 0) field.setGeometry(left, top, width, height) field.setVisible(width >= field.minimumWidth()) field.raise_() except RuntimeError: LOG.debug("the menu bar went away before the field was placed") def _corner_width(self) -> int: """How much of the bar the window marks already occupy.""" corner = self._bar.cornerWidget(Qt.Corner.TopRightCorner) try: return corner.width() if corner is not None else 0 except RuntimeError: return 0 def eventFilter(self, watched, event): # noqa: N802 """Re-place the field when the bar changes shape or language. :param watched: the menu bar. :param event: the event. :returns: False, always: this watches, it never consumes. """ if watched is self._bar and event.type() in ( QEvent.Resize, QEvent.Show, QEvent.LayoutRequest, QEvent.LanguageChange, QEvent.ActionChanged, QEvent.ActionAdded, QEvent.ActionRemoved): self.place() return False
[docs] def install(window: QMainWindow) -> Optional[HelpSearchField]: """Put the search field directly to the right of the Help menu. IN THE MENU ROW, NOT THE CORNER. The field was first installed in the menu bar's top-right corner widget, which put it beside the minimise, full screen and close marks at the far right of the window, away from the menus. It belongs directly to the right of Help, so it is placed in the menu row itself: the bar lays its actions out left to right, and Help is the last menu, so the field follows Help and moves with it when the menus are re-translated or re-ordered. Idempotent: a second call hands back the field the first one installed. :param window: the main window. :returns: the field, or ``None`` when there is no menu bar to hang it on. """ existing = field_of(window) if existing is not None: return existing try: bar = window.menuBar() except Exception: LOG.debug("no menu bar to hang the help search on", exc_info=True) return None if bar is None: return None field = HelpSearchField(window) field.setMinimumWidth(160) field.setMaximumWidth(280) field.setParent(bar) placer = _FieldPlacer(bar, field) bar.installEventFilter(placer) window._help_search_placer = placer placer.place() field.show() window._help_search = field return field
[docs] def install_window_hooks(window: QMainWindow) -> Optional[HelpSearchField]: """Install the field from :func:`spacr.qt.shortcuts.install`. Named for the convention the other window-scoped installers follow, so that ``_install_window_hooks`` reads as one list of the same thing. :param window: the main window. :returns: the field, or ``None``. """ return install(window)