# piker: trading gear for hackers
# Copyright (C) Tyler Goodlet (in stewardship for piker0)

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

"""
qompleterz: embeddable search and complete using trio, Qt and
rapidfuzz.

"""

# link set for hackzin on this stuff:
# https://doc.qt.io/qt-5/qheaderview.html#moving-header-sections
# https://doc.qt.io/qt-5/model-view-programming.html
# https://doc.qt.io/qt-5/modelview.html
# https://doc.qt.io/qt-5/qtreeview.html#selectedIndexes
# https://doc.qt.io/qt-5/qmodelindex.html#siblingAtColumn
# https://doc.qt.io/qt-5/qitemselectionmodel.html#currentIndex
# https://www.programcreek.com/python/example/108109/PyQt5.QtWidgets.QTreeView
# https://doc.qt.io/qt-5/qsyntaxhighlighter.html
# https://github.com/qutebrowser/qutebrowser/blob/master/qutebrowser/completion/completiondelegate.py#L243
# https://forum.qt.io/topic/61343/highlight-matched-substrings-in-qstyleditemdelegate

from collections import defaultdict
from contextlib import asynccontextmanager
from functools import partial
from typing import (
    Callable,
    Awaitable,
    Sequence,
    Any,
    AsyncIterator,
    Iterator,
)
import time
from pprint import pformat

from rapidfuzz import process as fuzzy
import tractor
import trio
from trio_typing import TaskStatus

from piker.ui.qt import (
    size_policy,
    align_flag,
    Qt,
    # QtCore,
    QtWidgets,
    QModelIndex,
    QItemSelectionModel,
    # QLayout,
    QStandardItem,
    QStandardItemModel,
    QWidget,
    QTreeView,
    # QListWidgetItem,
    # QAbstractScrollArea,
    # QStyledItemDelegate,
)
from ..log import get_logger
from ._style import (
    _font,
    hcolor,
)
from ._forms import Edit, FontScaledDelegate


log = get_logger(__name__)


class CompleterView(QTreeView):

    mode_name: str = 'search-nav'

    # XXX: relevant docs links:
    # - simple widget version of this:
    #   https://doc.qt.io/qt-5/qtreewidget.html#details
    # - MVC high level instructional:
    #   https://doc.qt.io/qt-5/model-view-programming.html
    # - MV tut:
    #   https://doc.qt.io/qt-5/modelview.html
    # - custome header view (for doing stuff like we have in  kivy?):
    #   https://doc.qt.io/qt-5/qheaderview.html#moving-header-sections

    # TODO: selection model stuff for eventual aggregate feeds
    # charting and mgmt;
    # https://doc.qt.io/qt-5/qabstractitemview.html#setSelectionModel
    # https://doc.qt.io/qt-5/qitemselectionmodel.html
    # https://doc.qt.io/qt-5/modelview.html#3-2-working-with-selections
    # https://doc.qt.io/qt-5/model-view-programming.html#handling-selections-of-items

    # TODO: mouse extended handling:
    # https://doc.qt.io/qt-5/qabstractitemview.html#entered

    def __init__(
        self,
        parent=None,
        labels: list[str] = [],
    ) -> None:

        super().__init__(parent)

        model = QStandardItemModel(self)
        self.labels = labels

        # a std "tabular" config
        self.setItemDelegate(FontScaledDelegate(self))
        self.setModel(model)
        self.setAlternatingRowColors(True)
        # TODO: size this based on DPI font
        self.setIndentation(_font.px_size)

        self.setUniformRowHeights(True)
        # self.setColumnWidth(0, 3)
        # self.setVerticalBarPolicy(Qt.ScrollBarAlwaysOff)
        # self.setSizeAdjustPolicy(QAbstractScrollArea.AdjustIgnored)

        # ux settings
        self.setSizePolicy(
            size_policy.Expanding,
            size_policy.Expanding,
        )
        self.setItemsExpandable(True)
        self.setExpandsOnDoubleClick(False)
        self.setAnimated(False)
        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)

        # column headers
        model.setHorizontalHeaderLabels(labels)

        self._font_size: int = 0  # pixels
        self._init: bool = False

    async def on_pressed(
        self,
        idx: QModelIndex,
    ) -> None:
        '''
        Mouse pressed on view handler.

        '''
        search = self.parent()

        await search.chart_current_item(
            clear_to_cache=True,
        )

        # XXX: this causes Qt to hang and segfault..lovely
        # self.show_cache_entries(
        #     only=True,
        #     keep_current_item_selected=True,
        # )

        search.focus()


    def set_font_size(self, size: int = 18):
        # print(size)
        if size < 0:
            size = 16

        self._font_size = size

        self.setStyleSheet(f"font: {size}px")

    def resize_to_results(
        self,
        w: float | None = 0,
        h: float | None = None,

    ) -> None:
        model = self.model()
        cols = model.columnCount()
        cidx = self.selectionModel().currentIndex()
        rows = model.rowCount()
        self.expandAll()

        # compute the approx height in pixels needed to include
        # all result rows in view.
        row_h = rows_h = self.rowHeight(cidx) * (rows + 1)
        for idx, item in self.iter_df_rows():
            row_h = self.rowHeight(idx)
            rows_h += row_h
            # print(f'row_h: {row_h}\nrows_h: {rows_h}')

            # TODO: could we just break early here on detection
            # of ``rows_h >= h``?

        col_w_tot = 0
        for i in range(cols):
            # only slap in a rows's height's worth
            # of padding once at startup.. no idea
            if (
                not self._init
                and row_h
            ):
                col_w_tot = row_h
                self._init = True

            self.resizeColumnToContents(i)
            col_w_tot += self.columnWidth(i)

        # NOTE: if the heigh `h` set here is **too large** then the
        # resize event will perpetually trigger as the window causes
        # some kind of recompute of callbacks.. so we have to ensure
        # it's limited.
        if h:
            h: int = round(h)
            abs_mx = round(0.91 * h)
            self.setMaximumHeight(abs_mx)

            if rows_h <= abs_mx:
                # self.setMinimumHeight(rows_h)
                self.setMinimumHeight(rows_h)
                # self.setFixedHeight(rows_h)

            else:
                self.setMinimumHeight(abs_mx)

        # dyncamically size to width of longest result seen
        curr_w = self.width()
        if curr_w < col_w_tot:
            self.setMinimumWidth(col_w_tot)

        self.update()

    def is_selecting_d1(self) -> bool:
        cidx = self.selectionModel().currentIndex()
        return cidx.parent() == QModelIndex()

    def previous_index(self) -> QModelIndex:

        cidx = self.selectionModel().currentIndex()
        one_above = self.indexAbove(cidx)

        if one_above.parent() == QModelIndex():
            # if the next node up's parent is the root we don't want to select
            # the next node up since it's a top level node and we only
            # select entries depth >= 2.

            # see if one more up is not the root and we can select it.
            two_above = self.indexAbove(one_above)
            if two_above != QModelIndex():
                return two_above
            else:
                return cidx

        return one_above  # just next up

    def next_index(self) -> QModelIndex:
        cidx = self.selectionModel().currentIndex()
        one_below = self.indexBelow(cidx)

        if one_below.parent() == QModelIndex():
            # if the next node up's parent is the root we don't want to select
            # the next node up since it's a top level node and we only
            # select entries depth >= 2.

            # see if one more up is not the root and we can select it.
            two_below = self.indexBelow(one_below)
            if two_below != QModelIndex():
                return two_below
            else:
                return cidx

        return one_below  # just next down

    def select_from_idx(

        self,
        idx: QModelIndex,

    ) -> QStandardItem:
        '''
        Select and return the item at index ``idx``.

        '''
        sel = self.selectionModel()
        model = self.model()

        sel.setCurrentIndex(
            idx,
            QItemSelectionModel.ClearAndSelect |
            QItemSelectionModel.Rows
        )

        return model.itemFromIndex(idx)

    def select_first(self) -> QStandardItem:
        '''
        Select the first depth >= 2 entry from the completer tree and
        return its item.

        '''
        # ensure we're **not** selecting the first level parent node and
        # instead its child.
        model = self.model()
        for idx, item in self.iter_d1():
            if model.rowCount(idx) == 0:
                continue
            else:
                return self.select_from_idx(self.indexBelow(idx))

    def select_next(self) -> QStandardItem:
        idx = self.next_index()
        assert idx.isValid()
        return self.select_from_idx(idx)

    def select_previous(self) -> QStandardItem:
        idx = self.previous_index()
        assert idx.isValid()
        return self.select_from_idx(idx)

    def next_section(self, direction: str = 'down') -> QModelIndex:
        cidx = start_idx = self.selectionModel().currentIndex()

        # step up levels to depth == 1
        while cidx.parent() != QModelIndex():
            cidx = cidx.parent()

        # move to next section in `direction`
        op = {'up': -1, 'down': +1}[direction]
        next_row = cidx.row() + op
        nidx = self.model().index(next_row, cidx.column(), QModelIndex())

        # do nothing, if there is no valid "next" section
        if not nidx.isValid():
            return self.select_from_idx(start_idx)

        # go to next selectable child item
        self.select_from_idx(nidx)
        return self.select_next()

    def iter_d1(
        self,
    ) -> tuple[QModelIndex, QStandardItem]:

        model = self.model()
        isections = model.rowCount()

        # much thanks to following code to figure out breadth-first
        # traversing from the root node:
        # https://stackoverflow.com/a/33126689
        for i in range(isections):
            idx = model.index(i, 0, QModelIndex())
            item = model.itemFromIndex(idx)
            yield idx, item

    def iter_df_rows(
        self,
        iparent: QModelIndex = QModelIndex(),

    ) -> Iterator[tuple[QModelIndex, QStandardItem]]:

        model = self.model()
        isections = model.rowCount(iparent)
        for i in range(isections):
            idx = model.index(i, 0, iparent)
            item = model.itemFromIndex(idx)
            yield idx, item

            if model.hasChildren(idx):
                # recursively yield child items depth-first
                yield from self.iter_df_rows(idx)

    def find_section(
        self,
        section: str,

    ) -> QModelIndex | None:
        '''
        Find the *first* depth = 1 section matching ``section`` in
        the tree and return its index.

        '''
        for idx, item in self.iter_d1():
            if item.text() == section:
                return idx
        else:
            # caller must expect his
            return None

    def clear_section(
        self,
        section: str,
        status_field: str = None,

    ) -> None:
        '''
        Clear all result-rows from under the depth = 1 section.

        '''
        idx = self.find_section(section)
        model = self.model()

        if idx is not None:
            if model.hasChildren(idx):
                rows = model.rowCount(idx)
                # print(f'removing {rows} from {section}')
                assert model.removeRows(0, rows, parent=idx)

            # remove section as well ?
            # model.removeRow(i, QModelIndex())

            if status_field is not None:
                model.setItem(idx.row(), 1, QStandardItem(status_field))

            else:
                model.setItem(idx.row(), 1, QStandardItem())

            return idx
        else:
            return None

    def set_section_entries(
        self,
        section: str,
        values: Sequence[str],
        clear_all: bool = False,
        reverse: bool = False,

    ) -> None:
        '''
        Set result-rows for depth = 1 tree section ``section``.

        '''
        if (
            values
            and not isinstance(values[0], str)
        ):
            flattened: list[str] = []
            for val in values:
                flattened.extend(val)

            values = flattened

        if reverse:
            values = reversed(values)

        model = self.model()
        if clear_all:
            # XXX: rewrite the model from scratch if caller requests it
            model.clear()

        model.setHorizontalHeaderLabels(self.labels)

        section_idx = self.clear_section(section)

        # if we can't find a section start adding to the root
        if section_idx is None:
            root = model.invisibleRootItem()
            section_item = QStandardItem(section)
            blank = QStandardItem('')
            root.appendRow([section_item, blank])

        else:
            section_item = model.itemFromIndex(section_idx)

        # values just needs to be sequence-like
        for i, s in enumerate(values):

            ix = QStandardItem(str(i))
            item = QStandardItem(s)

            # Add the item to the model
            section_item.appendRow([ix, item])

        self.expandAll()

        # TODO: figure out if we can avoid this line in a better way
        # such that "re-selection" doesn't happen tree-wise for each new
        # sub-search:
        # https://doc.qt.io/qt-5/model-view-programming.html#handling-selections-in-item-views

        # XXX: THE BELOW LINE MUST BE CALLED.
        # this stuff is super finicky and if not done right will cause
        # Qt crashes out our buttz. it's required in order to get the
        # view to show right after typing input.

        self.select_first()

        # TODO: the way we might be able to do this more sanely is,
        # 1. for the currently selected item, when start rewriting
        #    a section figure out the row, column, parent "abstract"
        #    position in the tree view and store it
        # 2. take that position and re-apply the selection to the new
        #    model/tree by looking up the new "equivalent element" and
        #    selecting

        self.show_matches()

    def show_matches(
        self,
        wh: tuple[float, float] | None = None,

    ) -> None:

        if wh:
            self.resize_to_results(*wh)
        else:
            # case where it's just an update from results and *NOT*
            # a resize of some higher level parent-container widget.
            search = self.parent()
            w, h = search.space_dims()
            self.resize_to_results(w=w, h=h)

        self.show()


class SearchBar(Edit):

    mode_name: str = 'search'

    def __init__(

        self,
        parent: QWidget,
        godwidget: QWidget,
        view: CompleterView | None = None,
        **kwargs,

    ) -> None:

        self.godwidget = godwidget
        super().__init__(parent, **kwargs)
        self.view: CompleterView = view

    def unfocus(self) -> None:
        self.parent().hide()
        self.clearFocus()

    def hide(self) -> None:
        if self.view:
            self.view.hide()
        super().hide()


class SearchWidget(QtWidgets.QWidget):
    '''
    Composed widget of ``SearchBar`` + ``CompleterView``.

    Includes helper methods for item management in the sub-widgets.

    '''
    mode_name: str = 'search'

    def __init__(
        self,
        godwidget: 'GodWidget',  # type: ignore # noqa
        columns: list[str] = ['src', 'symbol'],
        parent=None,

    ) -> None:
        super().__init__(parent)

        # size it as we specify
        self.setSizePolicy(
            size_policy.Fixed,
            size_policy.Fixed,
        )

        self.godwidget = godwidget
        godwidget.reg_for_resize(self)

        self.vbox = QtWidgets.QVBoxLayout(self)
        self.vbox.setContentsMargins(0, 4, 4, 0)
        self.vbox.setSpacing(4)

        # split layout for the (label:| search bar entry)
        self.bar_hbox = QtWidgets.QHBoxLayout()
        self.bar_hbox.setContentsMargins(0, 0, 0, 0)
        self.bar_hbox.setSpacing(4)

        # add label to left of search bar
        self.label = label = QtWidgets.QLabel(parent=self)
        label.setStyleSheet(
            f"""QLabel {{
                color : {hcolor('default_lightest')};
                font-size : {_font.px_size - 2}px;
            }}
            """
        )
        label.setTextFormat(
            Qt.TextFormat.MarkdownText
        )
        label.setFont(_font.font)
        label.setMargin(4)
        label.setText("search:")
        label.show()
        label.setAlignment(
            align_flag.AlignVCenter
            | align_flag.AlignLeft
        )

        self.bar_hbox.addWidget(label)

        self.view = CompleterView(
            parent=self,
            labels=columns,
        )
        self.bar = SearchBar(
            parent=self,
            view=self.view,
            godwidget=godwidget,
        )
        self.bar_hbox.addWidget(self.bar)

        self.vbox.addLayout(self.bar_hbox)

        self.vbox.setAlignment(
            self.bar,
            align_flag.AlignTop
            | align_flag.AlignRight,
        )
        self.vbox.addWidget(self.bar.view)
        self.vbox.setAlignment(
            self.view,
            align_flag.AlignTop
            | align_flag.AlignLeft,
        )

    def focus(self) -> None:
        self.show()
        self.bar.focus()

    def show_cache_entries(
        self,
        only: bool = False,
        keep_current_item_selected: bool = False,

    ) -> None:
        '''
        Clear the search results view and show only cached (aka recently
        loaded with active data) feeds in the results section.

        '''
        godw = self.godwidget

        # first entry in the cache is the current symbol(s)
        fqmes = set()
        for multi_fqmes in list(godw._chart_cache):
            for fqme in set(multi_fqmes):
                fqmes.add(fqme)

        if keep_current_item_selected:
            sel = self.view.selectionModel()
            cidx = sel.currentIndex()

        self.view.set_section_entries(
            'cache',
            list(fqmes),
            # remove all other completion results except for cache
            clear_all=only,
            reverse=True,
        )

        if (
            keep_current_item_selected
            and cidx.isValid()
        ):
            # set current selection back to what it was before filling out
            # the view results.
            self.view.select_from_idx(cidx)
        else:
            self.view.select_first()

    def get_current_item(self) -> tuple[QModelIndex, str, str] | None:
        '''
        Return the current completer tree selection as
        a tuple ``(parent: str, child: str)`` if valid, else ``None``.

        '''
        model = self.view.model()
        sel = self.view.selectionModel()
        cidx = sel.currentIndex()

        # TODO: get rid of this hard coded column -> 1
        # and use the ``CompleterView`` schema/settings
        # to figure out the desired field(s)
        # https://doc.qt.io/qt-5/qstandarditemmodel.html#itemFromIndex
        node = model.itemFromIndex(cidx.siblingAtColumn(1))

        if node:
            symbol = node.text()
            try:
                provider = node.parent().text()
            except AttributeError:
                # no text set
                return None

            # TODO: move this to somewhere non-search machinery specific?
            if provider == 'cache':
                symbol, _, provider = symbol.rpartition('.')

            return (
                cidx,
                provider,
                symbol,
            )

        else:
            return None

    async def chart_current_item(
        self,
        clear_to_cache: bool = True,

    ) -> str | None:
        '''
        Attempt to load and switch the current selected
        completion result to the affiliated chart app.

        Return any loaded symbol.

        '''
        value = self.get_current_item()
        if value is None:
            return None

        cidx, provider, symbol = value
        godw = self.godwidget

        fqme = f'{symbol}.{provider}'
        log.info(f'Requesting symbol: {fqme}')

        # assert provider in symbol
        await godw.load_symbols(
            fqmes=[fqme],
            loglevel='info',
        )

        # fully qualified symbol name (SNS i guess is what we're
        # making?)
        fqme = '.'.join([symbol, provider]).lower()

        if clear_to_cache:

            self.bar.clear()

            # Re-order the symbol cache on the chart to display in
            # LIFO order. this is normally only done internally by
            # the chart on new symbols being loaded into memory
            godw.set_chart_symbols(
                (fqme,), (
                    godw.hist_linked,
                    godw.rt_linked,
                )
            )
            self.show_cache_entries(
                only=True,
            )

        self.bar.focus()
        return fqme

    def space_dims(self) -> tuple[float, float]:
        '''
        Compute and return the "available space dimentions" for this
        search widget in terms of px space for results by return the
        pair of width and height.

        '''
        # XXX: dun need dis rite?
        # win = self.window()
        # win_h = win.height()
        # sb_h = win.statusBar().height()
        godw = self.godwidget
        hl = godw.hist_linked
        edit_h = self.bar.height()
        h = hl.height() - edit_h
        w = hl.width()
        return w, h

    def on_resize(self) -> None:
        '''
        Resize relay event from god, resize all child widgets.

        Right now this is just view to contents and/or the fast chart
        height.

        '''
        w, h = self.space_dims()
        self.bar.view.show_matches(wh=(w, h))


_search_active: trio.Event = trio.Event()
_search_enabled: bool = False


async def pack_matches(

    view: CompleterView,
    has_results: dict[str, set[str]],
    matches: dict[(str, str), list[str]],
    provider: str,
    pattern: str,
    search: Callable[..., Awaitable[dict]],

    task_status: TaskStatus[
        trio.CancelScope] = trio.TASK_STATUS_IGNORED,

) -> None:

    log.info(f'Searching {provider} for "{pattern}"')

    if provider != 'cache':
        # insert provider entries with search status
        view.set_section_entries(
            section=provider,
            values=[],
        )
        view.clear_section(provider, status_field='-> searchin..')

    else:  # for the cache just clear it's entries and don't put a status
        view.clear_section(provider)

    with trio.CancelScope() as cs:
        task_status.started(cs)
        # ensure ^ status is updated
        results = list(await search(pattern))

    # XXX: don't cache the cache results xD
    if provider != 'cache':
        matches[(provider, pattern)] = results

        # print(f'results from {provider}: {results}')
        has_results[pattern].add(provider)

    if results:
        # display completion results
        view.set_section_entries(
            section=provider,
            values=results,
        )
    else:
        view.clear_section(provider)


async def fill_results(

    search: SearchBar,
    recv_chan: trio.abc.ReceiveChannel,

    # kb debouncing pauses (bracket defaults)
    min_pause_time: float = 0.01,  # absolute min typing throttle

    # max pause required before slow relay
    max_pause_time: float = 6/16 + 0.001,

) -> None:
    '''
    Task to search through providers and fill in possible
    completion results.

    '''
    global _search_active, _search_enabled, _searcher_cache

    bar = search.bar
    view = bar.view
    view.select_from_idx(QModelIndex())

    last_text = bar.text()
    repeats = 0

    # cache of prior patterns to search results
    matches = defaultdict(list)
    has_results: defaultdict[str, set[str]] = defaultdict(set)

    # show cached feed list at startup
    search.show_cache_entries()
    search.on_resize()

    while True:
        await _search_active.wait()
        period = None

        while True:

            last_text = bar.text()
            wait_start = time.time()

            with trio.move_on_after(max_pause_time):
                pattern = await recv_chan.receive()

            period = time.time() - wait_start
            log.debug(f'{pattern} after {period}')

            # during fast multiple key inputs, wait until a pause
            # (in typing) to initiate search
            if period < min_pause_time:
                log.debug(f'Ignoring fast input for {pattern}')
                continue

            text = bar.text()
            # print(f'search: {text}')

            if not text or text.isspace():
                # print('idling')
                _search_active = trio.Event()
                break

            if text == last_text:
                repeats += 1

            if not _search_enabled:
                # print('search currently disabled')
                break

            already_has_results = has_results[text]
            log.debug(f'Search req for {text}')

            # issue multi-provider fan-out search request and place
            # "searching.." statuses on outstanding results providers
            async with (
                tractor.trionics.collapse_eg(),
                trio.open_nursery() as tn
            ):

                for provider, (search, pause) in (
                    _searcher_cache.copy().items()
                ):
                    # XXX: only conduct search on this backend if it's
                    # registered for the corresponding pause period AND
                    # it hasn't already been searched with the current
                    # input pattern (in which case just look up the old
                    # results).
                    if (
                        period >= pause
                        and provider not in already_has_results
                    ):

                        # TODO: it may make more sense TO NOT search the
                        # cache in a bg task since we know it's fully
                        # cpu-bound.
                        if provider != 'cache':
                            view.clear_section(
                                provider,
                                status_field='-> searchin..',
                            )

                        await tn.start(
                            pack_matches,
                            view,
                            has_results,
                            matches,
                            provider,
                            text,
                            search
                        )
                    else:  # already has results for this input text
                        results = matches[(provider, text)]

                        # TODO really for the cache we need an
                        # invalidation signal so that we only re-search
                        # the cache once it's been mutated by the chart
                        # switcher.. right now we're just always
                        # re-searching it's ``dict`` since it's easier
                        # but it also causes it to be slower then cached
                        # results from other providers on occasion.
                        if (
                            results
                        ):
                            if provider != 'cache':
                                view.set_section_entries(
                                    section=provider,
                                    values=results,
                                )
                            else:
                                # if provider == 'cache':
                                # for the cache just show what we got
                                # that matches
                                search.show_cache_entries()

                        else:
                            view.clear_section(provider)

            if repeats > 2 and period > max_pause_time:
                _search_active = trio.Event()
                repeats = 0
                break

            bar.show()


async def handle_keyboard_input(

    searchbar: SearchBar,
    recv_chan: trio.abc.ReceiveChannel,

) -> None:

    global _search_active, _search_enabled

    # startup
    searchw = searchbar.parent()
    godwidget = searchw.godwidget
    view = searchbar.view
    view.set_font_size(searchbar.dpi_font.px_size)
    send, recv = trio.open_memory_channel(616)

    async with (
        tractor.trionics.collapse_eg(),  # needed?
        trio.open_nursery() as tn
    ):
        # start a background multi-searcher task which receives
        # patterns relayed from this keyboard input handler and
        # async updates the completer view's results.
        tn.start_soon(
            partial(
                fill_results,
                searchw,
                recv,
            )
        )

        searchbar.focus()
        searchw.show_cache_entries()
        await trio.sleep(0)

        async for kbmsg in recv_chan:
            event, etype, key, mods, txt = kbmsg.to_tuple()

            log.debug(f'key: {key}, mods: {mods}, txt: {txt}')

            ctl = False
            if mods == Qt.ControlModifier:
                ctl = True

            if key in (
                Qt.Key_Enter,
                Qt.Key_Return
            ):
                _search_enabled = False
                await searchw.chart_current_item(clear_to_cache=True)

                # XXX: causes hang and segfault..
                # searchw.show_cache_entries(
                #     only=True,
                #     keep_current_item_selected=True,
                # )

                view.show_matches()
                searchw.focus()

            elif (
                not ctl
                and not searchbar.text()
            ):
                # TODO: really should factor this somewhere..bc
                # we're doin it in another spot as well..
                searchw.show_cache_entries(only=True)
                continue

            # cancel and close
            if ctl and key in {
                Qt.Key_C,
                Qt.Key_Space,   # i feel like this is the "native" one
                Qt.Key_Alt,
            }:
                searchbar.unfocus()

                # kill the search and focus back on main chart
                if godwidget:
                    godwidget.focus()

                continue

            if (
                ctl
                and key in {Qt.Key_L}
            ):
                # like url (link) highlight in a web browser
                searchbar.focus()

            # selection navigation controls
            elif (
                ctl
                and key in {Qt.Key_D}
            ):
                view.next_section(direction='down')
                _search_enabled = False

            elif (
                ctl
                and key in {Qt.Key_U}
            ):
                view.next_section(direction='up')
                _search_enabled = False

            # selection navigation controls
            elif (
                ctl and (
                    key in {
                        Qt.Key_K,
                        Qt.Key_J,
                    }

                    or key in {
                        Qt.Key_Up,
                        Qt.Key_Down,
                    }
                )
            ):
                _search_enabled = False

                if key in {
                    Qt.Key_K,
                    Qt.Key_Up
                }:
                    item = view.select_previous()

                elif key in {
                    Qt.Key_J,
                    Qt.Key_Down,
                }:
                    item = view.select_next()

                if item:
                    parent_item = item.parent()

                    # if we're in the cache section and thus the next
                    # selection is a cache item, switch and show it
                    # immediately since it should be very fast.
                    if (
                        parent_item
                        and parent_item.text() == 'cache'
                    ):
                        await searchw.chart_current_item(clear_to_cache=False)

            # ACTUAL SEARCH BLOCK #
            # where we fuzzy complete and fill out sections.
            elif not ctl:
                # relay to completer task
                _search_enabled = True
                send.send_nowait(searchw.bar.text())
                _search_active.set()


async def search_simple_dict(
    text: str,
    source: dict,

) -> dict[str, Any]:

    tokens: list[str] = []
    for key in source:
        match key:
            case str():
                tokens.append(key)
            case []:
                tokens.extend(key)

    # search routine can be specified as a function such
    # as in the case of the current app's local symbol cache
    matches = fuzzy.extract(
        text,
        tokens,
        score_cutoff=90,
    )
    log.info(
        'cache search results:\n'
        f'{pformat(matches)}'
    )
    return [item[0] for item in matches]


# cache of provider names to async search routines
_searcher_cache: dict[str, Callable[..., Awaitable]] = {}


@asynccontextmanager
async def register_symbol_search(

    provider_name: str,
    search_routine: Callable,
    pause_period: float | None = None,

) -> AsyncIterator[dict]:

    global _searcher_cache

    pause_period = pause_period or 0.1

    # deliver search func to consumer
    try:
        _searcher_cache[provider_name] = (search_routine, pause_period)
        yield search_routine

    finally:
        _searcher_cache.pop(provider_name)
