diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 257a77fb..5adc832c 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -24,7 +24,6 @@ from types import ModuleType from functools import partial from PyQt5 import QtCore, QtGui -from PyQt5 import QtWidgets import numpy as np import pyqtgraph as pg import tractor @@ -48,7 +47,6 @@ from ._graphics._ohlc import BarItems from ._graphics._curve import FastAppendCurve from ._style import ( _font, - DpiAwareFont, hcolor, CHART_MARGINS, _xaxis_at, @@ -56,6 +54,7 @@ from ._style import ( _bars_from_right_in_follow_mode, _bars_to_left_in_follow_mode, ) +from ._search import FontSizedQLineEdit from ..data._source import Symbol from ..data._sharedmem import ShmArray from .. import brokers @@ -71,99 +70,6 @@ from .. import fsp log = get_logger(__name__) -class FontSizedQLineEdit(QtWidgets.QLineEdit): - - def __init__( - self, - parent_chart: 'ChartSpace', - font: DpiAwareFont = _font, - ) -> None: - super().__init__(parent_chart) - - self.dpi_font = font - self.chart_app = parent_chart - - # size it as we specify - self.setSizePolicy( - QtWidgets.QSizePolicy.Fixed, - QtWidgets.QSizePolicy.Fixed, - ) - self.setFont(font.font) - - # witty bit of margin - self.setTextMargins(2, 2, 2, 2) - - def sizeHint(self) -> QtCore.QSize: - psh = super().sizeHint() - psh.setHeight(self.dpi_font.px_size + 2) - return psh - - def unfocus(self) -> None: - self.hide() - self.clearFocus() - - def keyPressEvent(self, ev: QtCore.QEvent) -> None: - # by default we don't markt it as consumed? - # ev.ignore() - super().keyPressEvent(ev) - - ev.accept() - # text = ev.text() - key = ev.key() - mods = ev.modifiers() - - ctrl = False - if mods == QtCore.Qt.ControlModifier: - ctrl = True - - if ctrl: - if key == QtCore.Qt.Key_C: - self.unfocus() - self.chart_app.linkedcharts.focus() - - # TODO: - elif key == QtCore.Qt.Key_K: - print('move up') - - elif key == QtCore.Qt.Key_J: - print('move down') - - elif key in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return): - print(f'Requesting symbol: {self.text()}') - symbol = self.text() - app = self.chart_app - self.chart_app.load_symbol( - app.linkedcharts.symbol.brokers[0], - symbol, - 'info', - ) - # self.hide() - self.unfocus() - - # if self._executing(): - # # ignore all key presses while executing, except for Ctrl-C - # if event.modifiers() == Qt.ControlModifier and key == Qt.Key_C: - # self._handle_ctrl_c() - # return True - - # handler = self._key_event_handlers.get(key) - # intercepted = handler and handler(event) - - # Assumes that Control+Key is a movement command, i.e. should not be - # handled as text insertion. However, on win10 AltGr is reported as - # Alt+Control which is why we handle this case like regular - # # keypresses, see #53: - # if not event.modifiers() & Qt.ControlModifier or \ - # event.modifiers() & Qt.AltModifier: - # self._keep_cursor_in_buffer() - - # if not intercepted and event.text(): - # intercepted = True - # self.insert_input_text(event.text()) - - # return False - - class ChartSpace(QtGui.QWidget): """High level widget which contains layouts for organizing lower level charts as well as other widgets used to control @@ -172,9 +78,9 @@ class ChartSpace(QtGui.QWidget): def __init__(self, parent=None): super().__init__(parent) - self.v_layout = QtGui.QVBoxLayout(self) - self.v_layout.setContentsMargins(0, 0, 0, 0) - self.v_layout.setSpacing(1) + self.vbox = QtGui.QVBoxLayout(self) + self.vbox.setContentsMargins(0, 0, 0, 0) + self.vbox.setSpacing(2) self.toolbar_layout = QtGui.QHBoxLayout() self.toolbar_layout.setContentsMargins(0, 0, 0, 0) @@ -184,8 +90,8 @@ class ChartSpace(QtGui.QWidget): # self.init_timeframes_ui() # self.init_strategy_ui() - self.v_layout.addLayout(self.toolbar_layout) - self.v_layout.addLayout(self.h_layout) + self.vbox.addLayout(self.toolbar_layout) + self.vbox.addLayout(self.h_layout) self._chart_cache = {} self.linkedcharts: 'LinkedSplitCharts' = None self.symbol_label: Optional[QtGui.QLabel] = None @@ -196,7 +102,16 @@ class ChartSpace(QtGui.QWidget): # search = self.search = QtWidgets.QLineEdit() self.search = FontSizedQLineEdit(self) self.search.unfocus() - self.v_layout.addWidget(self.search) + self.vbox.addWidget(self.search) + self.vbox.addWidget(self.search.view) + self.search.view.set_results([ + 'XMRUSD', + 'XBTUSD', + 'XMRXBT', + # 'XMRXBT', + # 'XDGUSD', + # 'ADAUSD', + ]) # search.installEventFilter(self) @@ -237,14 +152,13 @@ class ChartSpace(QtGui.QWidget): """ linkedcharts = self._chart_cache.get(symbol_key) - # switching to a new viewable chart - if not self.v_layout.isEmpty(): + if not self.vbox.isEmpty(): # XXX: this is CRITICAL especially with pixel buffer caching self.linkedcharts.hide() # XXX: pretty sure we don't need this # remove any existing plots? - # self.v_layout.removeWidget(self.linkedcharts) + # self.vbox.removeWidget(self.linkedcharts) # switching to a new viewable chart if linkedcharts is None or reset: @@ -258,14 +172,14 @@ class ChartSpace(QtGui.QWidget): symbol_key, loglevel, ) - self.v_layout.addWidget(linkedcharts) + self.vbox.addWidget(linkedcharts) self._chart_cache[symbol_key] = linkedcharts # chart is already in memory so just focus it if self.linkedcharts: self.linkedcharts.unfocus() - # self.v_layout.addWidget(linkedcharts) + # self.vbox.addWidget(linkedcharts) linkedcharts.show() linkedcharts.focus() self.linkedcharts = linkedcharts @@ -1348,7 +1262,6 @@ async def run_fsp( # data-array as first msg _ = await stream.receive() - conf['stream'] = stream conf['portal'] = portal shm = conf['shm'] @@ -1414,8 +1327,6 @@ async def run_fsp( chart._set_yrange() - stream = conf['stream'] - last = time.time() # update chart graphics @@ -1676,7 +1587,6 @@ async def _async_main( # configure global DPI aware font size _font.configure_to_dpi(screen) - # try: async with trio.open_nursery() as root_n: # set root nursery for spawning other charts/feeds @@ -1691,9 +1601,6 @@ async def _async_main( await trio.sleep_forever() - # finally: - # root_n.cancel() - def _main( sym: str, diff --git a/piker/ui/_search.py b/piker/ui/_search.py new file mode 100644 index 00000000..02929041 --- /dev/null +++ b/piker/ui/_search.py @@ -0,0 +1,415 @@ +# 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 . + +""" +qompleterz: embeddable search and complete using trio, Qt and fuzzywuzzy. + +""" +from typing import Dict, List, Optional +import sys + +from PyQt5 import QtCore, QtGui +from PyQt5.QtCore import Qt +from PyQt5 import QtWidgets + +from PyQt5.QtCore import ( + Qt, + QSize, + QModelIndex, + QItemSelectionModel, + # QStringListModel +) +from PyQt5.QtGui import ( + QStandardItem, + QStandardItemModel, +) +from PyQt5.QtWidgets import ( + QTreeView, + # QListWidgetItem, + QAbstractScrollArea, + QStyledItemDelegate, +) +from fuzzywuzzy import process + + +# from PyQt5.QtWidgets import QCompleter, QComboBox + +from ..log import get_logger +from ._style import ( + _font, + DpiAwareFont, + hcolor, +) + + +log = get_logger(__name__) + + +class SimpleDelegate(QStyledItemDelegate): + """ + Super simple view delegate to render text in the same + font size as the search widget. + + """ + + def __init__( + self, + parent=None, + font: DpiAwareFont = _font, + ) -> None: + super().__init__(parent) + self.dpi_font = font + + # def sizeHint(self, *args) -> QtCore.QSize: + # """ + # Scale edit box to size of dpi aware font. + + # """ + # psh = super().sizeHint(*args) + # # psh.setHeight(self.dpi_font.px_size + 2) + + # psh.setHeight(18) + # # psh.setHeight(18) + # return psh + + +class CompleterView(QTreeView): + + def __init__( + self, + parent=None, + ) -> None: + + super().__init__(parent) + + self._font_size: int = 0 # pixels + self._cache: Dict[str, List[str]] = {} + + # def viewportSizeHint(self) -> QtCore.QSize: + # vps = super().viewportSizeHint() + # return QSize(vps.width(), _font.px_size * 6 * 2) + + # def sizeHint(self) -> QtCore.QSize: + # """Scale completion results up to 6/16 of window. + # """ + # # height = self.window().height() * 1/6 + # # psh.setHeight(self.dpi_font.px_size * 6) + # # print(_font.px_size) + # height = _font.px_size * 6 * 2 + # # the default here is just the vp size without scroll bar + # # https://doc.qt.io/qt-5/qabstractscrollarea.html#viewportSizeHint + # vps = self.viewportSizeHint() + # # print(f'h: {height}\n{vps}') + # # psh.setHeight(12) + # return QSize(-1, height) + + def resize(self): + model = self.model() + cols = model.columnCount() + + for i in range(cols): + self.resizeColumnToContents(i) + + # inclusive of search bar and header "rows" in pixel terms + rows = model.rowCount() + 2 + # max_rows = 8 # 6 + search and headers + row_px = self.rowHeight(self.currentIndex()) + # print(f'font_h: {font_h}\n px_height: {px_height}') + + self.setMinimumSize(self.width(), rows * row_px) + self.setMaximumSize(self.width(), rows * row_px) + + def set_font_size(self, size: int = 18): + # dpi_px_size = _font.px_size + print(size) + if size < 0: + size = 16 + + self._font_size = size + + self.setStyleSheet(f"font: {size}px") + + def set_results( + self, + results: List[str], + ) -> None: + + model = self.model() + + for i, s in enumerate(results): + + ix = QStandardItem(str(i)) + item = QStandardItem(s) + # item.setCheckable(False) + + src = QStandardItem('kraken') + + # Add the item to the model + model.appendRow([src, ix, item]) + + def find_matches( + self, + field: str, + txt: str, + ) -> List[QStandardItem]: + model = self.model() + items = model.findItems( + txt, + Qt.MatchContains, + self.field_to_col(field), + ) + + +def mk_completer_view( + + labels: List[str], + +) -> QTreeView: + + tree = CompleterView() + model = QStandardItemModel(tree) + + # a std "tabular" config + tree.setItemDelegate(SimpleDelegate()) + tree.setModel(model) + tree.setAlternatingRowColors(True) + tree.setIndentation(1) + + # tree.setUniformRowHeights(True) + # tree.setColumnWidth(0, 3) + + # ux settings + tree.setItemsExpandable(True) + tree.setExpandsOnDoubleClick(False) + tree.setAnimated(False) + tree.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + tree.setSizeAdjustPolicy(QAbstractScrollArea.AdjustIgnored) + + # column headers + model.setHorizontalHeaderLabels(labels) + + return tree + + +class FontSizedQLineEdit(QtWidgets.QLineEdit): + + def __init__( + self, + parent_chart: 'ChartSpace', # noqa + view: Optional[CompleterView] = None, + font: DpiAwareFont = _font, + ) -> None: + super().__init__(parent_chart) + + # vbox = self.vbox = QtGui.QVBoxLayout(self) + # vbox.addWidget(self) + # self.vbox.setContentsMargins(0, 0, 0, 0) + # self.vbox.setSpacing(2) + + self._view: CompleterView = view + + self.dpi_font = font + self.chart_app = parent_chart + + # size it as we specify + self.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, + QtWidgets.QSizePolicy.Fixed, + ) + self.setFont(font.font) + + # witty bit of margin + self.setTextMargins(2, 2, 2, 2) + + # self.setContextMenuPolicy(Qt.CustomContextMenu) + # self.customContextMenuRequested.connect(self.show_menu) + # self.setStyleSheet(f"font: 18px") + + def focus(self) -> None: + self.clear() + self.show() + self.setFocus() + + def show(self) -> None: + super().show() + self.show_matches() + # self.view.show() + # self.view.resize() + + @property + def view(self) -> CompleterView: + + if self._view is None: + view = mk_completer_view(['src', 'i', 'symbol']) + + # print('yo') + # self.chart_app.vbox.addWidget(view) + # self.vbox.addWidget(view) + + self._view = view + + return self._view + + def show_matches(self): + view = self.view + view.set_font_size(self.dpi_font.px_size) + view.show() + # scale columns + view.resize() + + def sizeHint(self) -> QtCore.QSize: + """ + Scale edit box to size of dpi aware font. + + """ + psh = super().sizeHint() + psh.setHeight(self.dpi_font.px_size + 2) + # psh.setHeight(12) + return psh + + def unfocus(self) -> None: + self.hide() + self.clearFocus() + + if self.view: + self.view.hide() + + def keyPressEvent(self, ev: QtCore.QEvent) -> None: + # by default we don't markt it as consumed? + # ev.ignore() + super().keyPressEvent(ev) + + ev.accept() + # text = ev.text() + key = ev.key() + mods = ev.modifiers() + txt = self.text() + + if key in (Qt.Key_Enter, Qt.Key_Return): + + print(f'Requesting symbol: {self.text()}') + + # TODO: ensure there is a matching completion or error and + # do nothing + symbol = txt + + app = self.chart_app + self.chart_app.load_symbol( + app.linkedcharts.symbol.brokers[0], + symbol, + 'info', + ) + self.unfocus() + return + + ctrl = False + if mods == Qt.ControlModifier: + ctrl = True + + view = self.view + model = view.model() + nidx = cidx = view.currentIndex() + sel = view.selectionModel() + # sel.clear() + + # selection tips: + # - get parent: self.index(row, 0) + # - first item index: index = self.index(0, 0, parent) + + if ctrl: + # we're in select mode or cancelling + + if key == Qt.Key_C: + self.unfocus() + + # kill the search and focus back on main chart + if self.chart_app: + self.chart_app.linkedcharts.focus() + + return + + # result selection nav + if key in (Qt.Key_K, Qt.Key_J): + + if key == Qt.Key_K: + # self.view.setFocus() + nidx = view.indexAbove(cidx) + print('move up') + + elif key == Qt.Key_J: + # self.view.setFocus() + nidx = view.indexBelow(cidx) + print('move down') + + # select row without selecting.. :eye_rollzz: + # https://doc.qt.io/qt-5/qabstractitemview.html#setCurrentIndex + if nidx.isValid(): + sel.setCurrentIndex( + nidx, + QItemSelectionModel.ClearAndSelect | + QItemSelectionModel.Rows + ) + + # TODO: make this not hard coded to 2 + # and use the ``CompleterView`` schema/settings + # to figure out the desired field(s) + value = model.item(nidx.row(), 2).text() + print(f'value: {value}') + self.setText(value) + + else: + sel.setCurrentIndex( + model.index(0, 0, QModelIndex()), + QItemSelectionModel.ClearAndSelect | # type: ignore[arg-type] + QItemSelectionModel.Rows + ) + + self.show_matches() + + +if __name__ == '__main__': + + # local testing of **just** the search UI + app = QtWidgets.QApplication(sys.argv) + + syms = [ + 'XMRUSD', + 'XBTUSD', + 'XMRXBT', + 'XDGUSD', + 'ADAUSD', + ] + + # results.setFocusPolicy(Qt.NoFocus) + search = FontSizedQLineEdit(None, view=view) + search.view.set_results(syms) + + # make a root widget to tie shit together + class W(QtGui.QWidget): + def __init__(self, parent=None): + super().__init__(parent) + vbox = self.vbox = QtGui.QVBoxLayout(self) + self.vbox.setContentsMargins(0, 0, 0, 0) + self.vbox.setSpacing(2) + + main = W() + main.vbox.addWidget(search) + main.vbox.addWidget(view) + # main.show() + search.show() + + sys.exit(app.exec_())