Merge pull request #189 from pikers/status_bar

Basic status bar
msgspec_fixes
goodboy 2021-06-14 00:01:01 -04:00 committed by GitHub
commit e52ecbe589
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 375 additions and 158 deletions

View File

@ -52,7 +52,6 @@ from ._l1 import L1Labels
from ._graphics._ohlc import BarItems from ._graphics._ohlc import BarItems
from ._graphics._curve import FastAppendCurve from ._graphics._curve import FastAppendCurve
from ._style import ( from ._style import (
_font,
hcolor, hcolor,
CHART_MARGINS, CHART_MARGINS,
_xaxis_at, _xaxis_at,
@ -68,7 +67,7 @@ from ..data import maybe_open_shm_array
from .. import brokers from .. import brokers
from .. import data from .. import data
from ..log import get_logger from ..log import get_logger
from ._exec import run_qtractor, current_screen from ._exec import run_qtractor
from ._interaction import ChartView from ._interaction import ChartView
from .order_mode import start_order_mode from .order_mode import start_order_mode
from .. import fsp from .. import fsp
@ -79,10 +78,11 @@ log = get_logger(__name__)
class ChartSpace(QtGui.QWidget): class ChartSpace(QtGui.QWidget):
"""High level widget which contains layouts for organizing '''Highest level composed widget which contains layouts for
lower level charts as well as other widgets used to control organizing lower level charts as well as other widgets used to
or modify them. control or modify them.
"""
'''
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@ -431,6 +431,8 @@ class ChartPlotWidget(pg.PlotWidget):
_l1_labels: L1Labels = None _l1_labels: L1Labels = None
mode_name: str = 'mode: view'
# TODO: can take a ``background`` color setting - maybe there's # TODO: can take a ``background`` color setting - maybe there's
# a better one? # a better one?
@ -1000,7 +1002,7 @@ async def test_bed(
_clear_throttle_rate: int = 60 # Hz _clear_throttle_rate: int = 60 # Hz
_book_throttle_rate: int = 20 # Hz _book_throttle_rate: int = 16 # Hz
async def chart_from_quotes( async def chart_from_quotes(
@ -1078,7 +1080,14 @@ async def chart_from_quotes(
tick_margin = 2 * tick_size tick_margin = 2 * tick_size
last_ask = last_bid = last_clear = time.time() last_ask = last_bid = last_clear = time.time()
chart.show()
async for quotes in stream: async for quotes in stream:
# chart isn't actively shown so just skip render cycle
if chart._lc.isHidden():
continue
for sym, quote in quotes.items(): for sym, quote in quotes.items():
now = time.time() now = time.time()
@ -1291,6 +1300,9 @@ async def run_fsp(
This is called once for each entry in the fsp This is called once for each entry in the fsp
config map. config map.
""" """
done = linked_charts.window().status_bar.open_status(
f'loading FSP: {display_name}..')
async with portal.open_stream_from( async with portal.open_stream_from(
# subactor entrypoint # subactor entrypoint
@ -1384,13 +1396,20 @@ async def run_fsp(
last = time.time() last = time.time()
done()
# update chart graphics # update chart graphics
async for value in stream: async for value in stream:
# chart isn't actively shown so just skip render cycle
if chart._lc.isHidden():
continue
now = time.time() now = time.time()
period = now - last period = now - last
# if period <= 1/30: # if period <= 1/30:
if period <= 1/_clear_throttle_rate - 0.001: if period <= 1/_clear_throttle_rate:
# faster then display refresh rate # faster then display refresh rate
# print(f'quote too fast: {1/period}') # print(f'quote too fast: {1/period}')
continue continue
@ -1479,7 +1498,7 @@ async def check_for_new_bars(feed, ohlcv, linked_charts):
async def chart_symbol( async def chart_symbol(
chart_app: ChartSpace, chart_app: ChartSpace,
brokername: str, provider: str,
sym: str, sym: str,
loglevel: str, loglevel: str,
) -> None: ) -> None:
@ -1489,11 +1508,14 @@ async def chart_symbol(
can be viewed and switched between extremely fast. can be viewed and switched between extremely fast.
""" """
sbar = chart_app.window.status_bar
loading_sym_done = sbar.open_status(f'loading {sym}.{provider}..')
# historical data fetch # historical data fetch
brokermod = brokers.get_brokermod(brokername) brokermod = brokers.get_brokermod(provider)
async with data.open_feed( async with data.open_feed(
brokername, provider,
[sym], [sym],
loglevel=loglevel, loglevel=loglevel,
) as feed: ) as feed:
@ -1526,6 +1548,8 @@ async def chart_symbol(
add_label=False, add_label=False,
) )
loading_sym_done()
# size view to data once at outset # size view to data once at outset
chart._set_yrange() chart._set_yrange()
@ -1604,7 +1628,7 @@ async def chart_symbol(
# linked_charts, # linked_charts,
# ) # )
await start_order_mode(chart, symbol, brokername) await start_order_mode(chart, symbol, provider)
async def load_providers( async def load_providers(
@ -1621,7 +1645,7 @@ async def load_providers(
# search engines. # search engines.
for broker in brokernames: for broker in brokernames:
log.info(f'Loading brokerd for {broker}') log.info(f'loading brokerd for {broker}..')
# spin up broker daemons for each provider # spin up broker daemons for each provider
portal = await stack.enter_async_context( portal = await stack.enter_async_context(
maybe_spawn_brokerd( maybe_spawn_brokerd(
@ -1645,7 +1669,7 @@ async def load_providers(
async def _async_main( async def _async_main(
# implicit required argument provided by ``qtractor_run()`` # implicit required argument provided by ``qtractor_run()``
widgets: Dict[str, Any], main_widget: ChartSpace,
sym: str, sym: str,
brokernames: str, brokernames: str,
@ -1659,10 +1683,10 @@ async def _async_main(
""" """
chart_app = widgets['main'] chart_app = main_widget
# attempt to configure DPI aware font size # attempt to configure DPI aware font size
screen = current_screen() screen = chart_app.window.current_screen()
# configure graphics update throttling based on display refresh rate # configure graphics update throttling based on display refresh rate
global _clear_throttle_rate global _clear_throttle_rate
@ -1672,8 +1696,11 @@ async def _async_main(
) )
log.info(f'Set graphics update rate to {_clear_throttle_rate} Hz') log.info(f'Set graphics update rate to {_clear_throttle_rate} Hz')
# configure global DPI aware font size # TODO: do styling / themeing setup
_font.configure_to_dpi(screen) # _style.style_ze_sheets(chart_app)
sbar = chart_app.window.status_bar
starting_done = sbar.open_status('starting ze chartz...')
async with trio.open_nursery() as root_n: async with trio.open_nursery() as root_n:
@ -1702,8 +1729,6 @@ async def _async_main(
# this internally starts a ``chart_symbol()`` task above # this internally starts a ``chart_symbol()`` task above
chart_app.load_symbol(provider, symbol, loglevel) chart_app.load_symbol(provider, symbol, loglevel)
root_n.start_soon(load_providers, brokernames, loglevel)
# spin up a search engine for the local cached symbol set # spin up a search engine for the local cached symbol set
async with _search.register_symbol_search( async with _search.register_symbol_search(
@ -1712,8 +1737,14 @@ async def _async_main(
_search.search_simple_dict, _search.search_simple_dict,
source=chart_app._chart_cache, source=chart_app._chart_cache,
), ),
# cache is super fast so debounce on super short period
pause_period=0.01,
): ):
# load other providers into search **after**
# the chart's select cache
root_n.start_soon(load_providers, brokernames, loglevel)
# start handling search bar kb inputs # start handling search bar kb inputs
async with open_key_stream( async with open_key_stream(
search.bar, search.bar,
@ -1727,6 +1758,7 @@ async def _async_main(
key_stream, key_stream,
) )
starting_done()
await trio.sleep_forever() await trio.sleep_forever()

View File

@ -21,10 +21,7 @@ Run ``trio`` in guest mode on top of the Qt event loop.
All global Qt runtime settings are mostly defined here. All global Qt runtime settings are mostly defined here.
""" """
from typing import Tuple, Callable, Dict, Any from typing import Tuple, Callable, Dict, Any
import os
import platform import platform
import signal
import time
import traceback import traceback
# Qt specific # Qt specific
@ -32,20 +29,22 @@ import PyQt5 # noqa
import pyqtgraph as pg import pyqtgraph as pg
from pyqtgraph import QtGui from pyqtgraph import QtGui
from PyQt5 import QtCore from PyQt5 import QtCore
# from PyQt5.QtGui import QLabel, QStatusBar
from PyQt5.QtCore import ( from PyQt5.QtCore import (
pyqtRemoveInputHook, pyqtRemoveInputHook,
Qt, Qt,
QCoreApplication, QCoreApplication,
) )
import qdarkstyle import qdarkstyle
from qdarkstyle import DarkPalette
# import qdarkgraystyle # import qdarkgraystyle
import trio import trio
import tractor
from outcome import Error from outcome import Error
from .._daemon import maybe_open_pikerd, _tractor_kwargs from .._daemon import maybe_open_pikerd, _tractor_kwargs
from ..log import get_logger from ..log import get_logger
from ._pg_overrides import _do_overrides from ._pg_overrides import _do_overrides
from . import _style
log = get_logger(__name__) log = get_logger(__name__)
@ -59,37 +58,6 @@ pg.enableExperimental = True
_do_overrides() _do_overrides()
# singleton app per actor
_qt_app: QtGui.QApplication = None
_qt_win: QtGui.QMainWindow = None
def current_screen() -> QtGui.QScreen:
"""Get a frickin screen (if we can, gawd).
"""
global _qt_win, _qt_app
start = time.time()
tries = 3
for _ in range(3):
screen = _qt_app.screenAt(_qt_win.pos())
print(f'trying to get screen....')
if screen is None:
time.sleep(0.5)
continue
break
else:
if screen is None:
# try for the first one we can find
screen = _qt_app.screens()[0]
assert screen, "Wow Qt is dumb as shit and has no screen..."
return screen
# XXX: pretty sure none of this shit works on linux as per: # XXX: pretty sure none of this shit works on linux as per:
# https://bugreports.qt.io/browse/QTBUG-53022 # https://bugreports.qt.io/browse/QTBUG-53022
# it seems to work on windows.. no idea wtf is up. # it seems to work on windows.. no idea wtf is up.
@ -104,33 +72,12 @@ if platform.system() == "Windows":
QCoreApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) QCoreApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
class MainWindow(QtGui.QMainWindow):
size = (800, 500)
title = 'piker chart (ur symbol is loading bby)'
def __init__(self, parent=None):
super().__init__(parent)
self.setMinimumSize(*self.size)
self.setWindowTitle(self.title)
def closeEvent(
self,
event: QtGui.QCloseEvent,
) -> None:
"""Cancel the root actor asap.
"""
# raising KBI seems to get intercepted by by Qt so just use the system.
os.kill(os.getpid(), signal.SIGINT)
def run_qtractor( def run_qtractor(
func: Callable, func: Callable,
args: Tuple, args: Tuple,
main_widget: QtGui.QWidget, main_widget: QtGui.QWidget,
tractor_kwargs: Dict[str, Any] = {}, tractor_kwargs: Dict[str, Any] = {},
window_type: QtGui.QMainWindow = MainWindow, window_type: QtGui.QMainWindow = None,
) -> None: ) -> None:
# avoids annoying message when entering debugger from qt loop # avoids annoying message when entering debugger from qt loop
pyqtRemoveInputHook() pyqtRemoveInputHook()
@ -148,10 +95,6 @@ def run_qtractor(
# XXX: lmfao, this is how you disable text edit cursor blinking..smh # XXX: lmfao, this is how you disable text edit cursor blinking..smh
app.setCursorFlashTime(0) app.setCursorFlashTime(0)
# set global app singleton
global _qt_app
_qt_app = app
# This code is from Nathaniel, and I quote: # This code is from Nathaniel, and I quote:
# "This is substantially faster than using a signal... for some # "This is substantially faster than using a signal... for some
# reason Qt signal dispatch is really slow (and relies on events # reason Qt signal dispatch is really slow (and relies on events
@ -188,19 +131,33 @@ def run_qtractor(
app.quit() app.quit()
# load dark theme # load dark theme
app.setStyleSheet(qdarkstyle.load_stylesheet(qt_api='pyqt5')) stylesheet = qdarkstyle.load_stylesheet(
qt_api='pyqt5',
palette=DarkPalette,
)
app.setStyleSheet(stylesheet)
# make window and exec # make window and exec
from . import _window
if window_type is None:
window_type = _window.MainWindow
window = window_type() window = window_type()
# set global app's main window singleton
_window._qt_win = window
# configure global DPI aware font sizes now that a screen
# should be active from which we can read a DPI.
_style._config_fonts_to_screen()
# hook into app focus change events
app.focusChanged.connect(window.on_focus_change)
instance = main_widget() instance = main_widget()
instance.window = window instance.window = window
widgets = {
'window': window,
'main': instance,
}
# override tractor's defaults # override tractor's defaults
tractor_kwargs.update(_tractor_kwargs) tractor_kwargs.update(_tractor_kwargs)
@ -210,7 +167,7 @@ def run_qtractor(
async with maybe_open_pikerd( async with maybe_open_pikerd(
**tractor_kwargs, **tractor_kwargs,
): ):
await func(*((widgets,) + args)) await func(*((instance,) + args))
# guest mode entry # guest mode entry
trio.lowlevel.start_guest_run( trio.lowlevel.start_guest_run(
@ -223,11 +180,6 @@ def run_qtractor(
window.main_widget = main_widget window.main_widget = main_widget
window.setCentralWidget(instance) window.setCentralWidget(instance)
# store global ref
# set global app singleton
global _qt_win
_qt_win = window
# actually render to screen # actually render to screen
window.show() window.show()
app.exec_() app.exec_()

View File

@ -31,6 +31,7 @@ from .._style import (
_xaxis_at, _xaxis_at,
hcolor, hcolor,
_font, _font,
_font_small,
) )
from .._axes import YAxisLabel, XAxisLabel from .._axes import YAxisLabel, XAxisLabel
from ...log import get_logger from ...log import get_logger
@ -109,7 +110,7 @@ class LineDot(pg.CurvePoint):
return False return False
# TODO: change this into our own ``Label`` # TODO: change this into our own ``_label.Label``
class ContentsLabel(pg.LabelItem): class ContentsLabel(pg.LabelItem):
"""Label anchored to a ``ViewBox`` typically for displaying """Label anchored to a ``ViewBox`` typically for displaying
datum-wise points from the "viewed" contents. datum-wise points from the "viewed" contents.
@ -138,22 +139,14 @@ class ContentsLabel(pg.LabelItem):
justify_text: str = 'left', justify_text: str = 'left',
font_size: Optional[int] = None, font_size: Optional[int] = None,
) -> None: ) -> None:
font_size = font_size or _font.font.pixelSize()
font_size = font_size or _font_small.px_size
super().__init__( super().__init__(
justify=justify_text, justify=justify_text,
size=f'{str(font_size)}px' size=f'{str(font_size)}px'
) )
if _font._physical_dpi >= 97:
# ad-hoc scale it based on boundingRect
# TODO: need proper fix for this?
typical_br = _font._qfm.boundingRect('Qyp')
anchor_font_size = math.ceil(typical_br.height() * 1.25)
else:
anchor_font_size = font_size
# anchor to viewbox # anchor to viewbox
self.setParentItem(chart._vb) self.setParentItem(chart._vb)
chart.scene().addItem(self) chart.scene().addItem(self)
@ -165,7 +158,7 @@ class ContentsLabel(pg.LabelItem):
ydim = margins[1] ydim = margins[1]
if inspect.isfunction(margins[1]): if inspect.isfunction(margins[1]):
margins = margins[0], ydim(anchor_font_size) margins = margins[0], ydim(font_size)
self.anchor(itemPos=index, parentPos=index, offset=margins) self.anchor(itemPos=index, parentPos=index, offset=margins)

View File

@ -467,6 +467,9 @@ class ChartView(ViewBox):
- zoom on right-click-n-drag to cursor position - zoom on right-click-n-drag to cursor position
""" """
mode_name: str = 'mode: view'
def __init__( def __init__(
self, self,
parent: pg.PlotItem = None, parent: pg.PlotItem = None,

View File

@ -96,6 +96,8 @@ class SimpleDelegate(QStyledItemDelegate):
class CompleterView(QTreeView): class CompleterView(QTreeView):
mode_name: str = 'mode: search-nav'
# XXX: relevant docs links: # XXX: relevant docs links:
# - simple widget version of this: # - simple widget version of this:
# https://doc.qt.io/qt-5/qtreewidget.html#details # https://doc.qt.io/qt-5/qtreewidget.html#details
@ -153,13 +155,14 @@ class CompleterView(QTreeView):
self._font_size: int = 0 # pixels self._font_size: int = 0 # pixels
def on_pressed(self, idx: QModelIndex) -> None: def on_pressed(self, idx: QModelIndex) -> None:
'''Mouse pressed on view handler.
'''
search = self.parent() search = self.parent()
search.chart_current_item(clear_to_cache=False) search.chart_current_item(clear_to_cache=False)
search.focus() search.focus()
def set_font_size(self, size: int = 18): def set_font_size(self, size: int = 18):
# dpi_px_size = _font.px_size
# print(size) # print(size)
if size < 0: if size < 0:
size = 16 size = 16
@ -424,6 +427,8 @@ class CompleterView(QTreeView):
class SearchBar(QtWidgets.QLineEdit): class SearchBar(QtWidgets.QLineEdit):
mode_name: str = 'mode: search'
def __init__( def __init__(
self, self,
@ -487,6 +492,8 @@ class SearchWidget(QtGui.QWidget):
Includes helper methods for item management in the sub-widgets. Includes helper methods for item management in the sub-widgets.
''' '''
mode_name: str = 'mode: search'
def __init__( def __init__(
self, self,
chart_space: 'ChartSpace', # type: ignore # noqa chart_space: 'ChartSpace', # type: ignore # noqa
@ -499,7 +506,7 @@ class SearchWidget(QtGui.QWidget):
# size it as we specify # size it as we specify
self.setSizePolicy( self.setSizePolicy(
QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed,
QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed,
) )
self.chart_app = chart_space self.chart_app = chart_space
@ -618,13 +625,15 @@ class SearchWidget(QtGui.QWidget):
# making?) # making?)
fqsn = '.'.join([symbol, provider]).lower() fqsn = '.'.join([symbol, provider]).lower()
if clear_to_cache:
self.bar.clear()
# Re-order the symbol cache on the chart to display in # Re-order the symbol cache on the chart to display in
# LIFO order. this is normally only done internally by # LIFO order. this is normally only done internally by
# the chart on new symbols being loaded into memory # the chart on new symbols being loaded into memory
chart.set_chart_symbol(fqsn, chart.linkedcharts) chart.set_chart_symbol(fqsn, chart.linkedcharts)
if clear_to_cache:
self.bar.clear()
self.view.set_section_entries( self.view.set_section_entries(
'cache', 'cache',
values=list(reversed(chart._chart_cache)), values=list(reversed(chart._chart_cache)),
@ -692,8 +701,10 @@ async def fill_results(
recv_chan: trio.abc.ReceiveChannel, recv_chan: trio.abc.ReceiveChannel,
# kb debouncing pauses (bracket defaults) # kb debouncing pauses (bracket defaults)
min_pause_time: float = 0.1, min_pause_time: float = 0.01, # absolute min typing throttle
max_pause_time: float = 6/16,
# max pause required before slow relay
max_pause_time: float = 6/16 + 0.001,
) -> None: ) -> None:
"""Task to search through providers and fill in possible """Task to search through providers and fill in possible
@ -742,11 +753,6 @@ async def fill_results(
_search_active = trio.Event() _search_active = trio.Event()
break break
if repeats > 2 and period >= max_pause_time:
_search_active = trio.Event()
repeats = 0
break
if text == last_text: if text == last_text:
repeats += 1 repeats += 1
@ -754,9 +760,8 @@ async def fill_results(
# print('search currently disabled') # print('search currently disabled')
break break
log.debug(f'Search req for {text}')
already_has_results = has_results[text] already_has_results = has_results[text]
log.debug(f'Search req for {text}')
# issue multi-provider fan-out search request and place # issue multi-provider fan-out search request and place
# "searching.." statuses on outstanding results providers # "searching.." statuses on outstanding results providers
@ -765,16 +770,22 @@ async def fill_results(
for provider, (search, pause) in ( for provider, (search, pause) in (
_searcher_cache.copy().items() _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': if provider != 'cache':
view.clear_section( view.clear_section(
provider, status_field='-> searchin..') provider, status_field='-> searchin..')
# only conduct search on this backend if it's
# registered for the corresponding pause period.
if (period >= pause) and (
provider not in already_has_results
):
await n.start( await n.start(
pack_matches, pack_matches,
view, view,
@ -786,6 +797,14 @@ async def fill_results(
) )
else: # already has results for this input text else: # already has results for this input text
results = matches[(provider, 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 and provider != 'cache': if results and provider != 'cache':
view.set_section_entries( view.set_section_entries(
section=provider, section=provider,
@ -794,6 +813,11 @@ async def fill_results(
else: else:
view.clear_section(provider) view.clear_section(provider)
if repeats > 2 and period > max_pause_time:
_search_active = trio.Event()
repeats = 0
break
bar.show() bar.show()
@ -952,7 +976,7 @@ async def register_symbol_search(
global _searcher_cache global _searcher_cache
pause_period = pause_period or 0.125 pause_period = pause_period or 0.1
# deliver search func to consumer # deliver search func to consumer
try: try:

View File

@ -22,18 +22,19 @@ import math
import pyqtgraph as pg import pyqtgraph as pg
from PyQt5 import QtCore, QtGui from PyQt5 import QtCore, QtGui
from qdarkstyle.palette import DarkPalette from qdarkstyle import DarkPalette
from ..log import get_logger from ..log import get_logger
from ._exec import current_screen
log = get_logger(__name__) log = get_logger(__name__)
_magic_inches = 0.0666 * (1 + 6/16)
# chart-wide fonts specified in inches # chart-wide fonts specified in inches
_font_sizes: Dict[str, Dict[str, float]] = { _font_sizes: Dict[str, Dict[str, float]] = {
'hi': { 'hi': {
'default': 0.0616, 'default': _magic_inches,
'small': 0.055, 'small': 0.9 * _magic_inches,
}, },
'lo': { 'lo': {
'default': 6.5 / 64, 'default': 6.5 / 64,
@ -43,6 +44,7 @@ _font_sizes: Dict[str, Dict[str, float]] = {
class DpiAwareFont: class DpiAwareFont:
def __init__( def __init__(
self, self,
# TODO: move to config # TODO: move to config
@ -52,10 +54,10 @@ class DpiAwareFont:
) -> None: ) -> None:
self.name = name self.name = name
self._qfont = QtGui.QFont(name) self._qfont = QtGui.QFont(name)
# self._iwl = size_in_inches or _default_font_inches_we_like
self._font_size: str = font_size self._font_size: str = font_size
self._qfm = QtGui.QFontMetrics(self._qfont) self._qfm = QtGui.QFontMetrics(self._qfont)
self._physical_dpi = None self._physical_dpi = None
self._font_inches: float = None
self._screen = None self._screen = None
def _set_qfont_px_size(self, px_size: int) -> None: def _set_qfont_px_size(self, px_size: int) -> None:
@ -64,13 +66,15 @@ class DpiAwareFont:
@property @property
def screen(self) -> QtGui.QScreen: def screen(self) -> QtGui.QScreen:
from ._window import main_window
if self._screen is not None: if self._screen is not None:
try: try:
self._screen.refreshRate() self._screen.refreshRate()
except RuntimeError: except RuntimeError:
self._screen = current_screen() self._screen = main_window().current_screen()
else: else:
self._screen = current_screen() self._screen = main_window().current_screen()
return self._screen return self._screen
@ -95,22 +99,34 @@ class DpiAwareFont:
# take the max since scaling can make things ugly in some cases # take the max since scaling can make things ugly in some cases
pdpi = screen.physicalDotsPerInch() pdpi = screen.physicalDotsPerInch()
ldpi = screen.logicalDotsPerInch() ldpi = screen.logicalDotsPerInch()
dpi = max(pdpi, ldpi) mx_dpi = max(pdpi, ldpi)
mn_dpi = min(pdpi, ldpi)
scale = round(ldpi/pdpi)
# for low dpi scale everything down if mx_dpi <= 97: # for low dpi use larger font sizes
if dpi <= 97:
inches = _font_sizes['lo'][self._font_size] inches = _font_sizes['lo'][self._font_size]
else:
else: # hidpi use smaller font sizes
inches = _font_sizes['hi'][self._font_size] inches = _font_sizes['hi'][self._font_size]
dpi = mn_dpi
# dpi is likely somewhat scaled down so use slightly larger font size
if scale > 1 and self._font_size:
# TODO: this denominator should probably be determined from
# relative aspect rations or something?
inches = inches * (1 / scale) * (1 + 6/16)
dpi = mx_dpi
self._font_inches = inches
font_size = math.floor(inches * dpi) font_size = math.floor(inches * dpi)
log.info( log.info(
f"\nscreen:{screen.name()} with DPI: {dpi}" f"\nscreen:{screen.name()} with pDPI: {pdpi}, lDPI: {ldpi}"
f"\nbest font size is {font_size}\n" f"\nOur best guess font size is {font_size}\n"
) )
# apply the size
self._set_qfont_px_size(font_size) self._set_qfont_px_size(font_size)
self._physical_dpi = dpi
def boundingRect(self, value: str) -> QtCore.QRectF: def boundingRect(self, value: str) -> QtCore.QRectF:
@ -130,12 +146,20 @@ class DpiAwareFont:
# use inches size to be cross-resolution compatible? # use inches size to be cross-resolution compatible?
_font = DpiAwareFont() _font = DpiAwareFont()
_font_small = DpiAwareFont(font_size='small')
def _config_fonts_to_screen() -> None:
'configure global DPI aware font sizes'
global _font, _font_small
_font.configure_to_dpi()
_font_small.configure_to_dpi()
# TODO: re-compute font size when main widget switches screens? # TODO: re-compute font size when main widget switches screens?
# https://forum.qt.io/topic/54136/how-do-i-get-the-qscreen-my-widget-is-on-qapplication-desktop-screen-returns-a-qwidget-and-qobject_cast-qscreen-returns-null/3 # https://forum.qt.io/topic/54136/how-do-i-get-the-qscreen-my-widget-is-on-qapplication-desktop-screen-returns-a-qwidget-and-qobject_cast-qscreen-returns-null/3
# _i3_rgba = QtGui.QColor.fromRgbF(*[0.14]*3 + [1])
# splitter widget config # splitter widget config
_xaxis_at = 'bottom' _xaxis_at = 'bottom'
@ -175,6 +199,7 @@ def hcolor(name: str) -> str:
'gray': '#808080', # like the kick 'gray': '#808080', # like the kick
'grayer': '#4c4c4c', 'grayer': '#4c4c4c',
'grayest': '#3f3f3f', 'grayest': '#3f3f3f',
'i3': '#494D4F',
'jet': '#343434', 'jet': '#343434',
'cadet': '#91A3B0', 'cadet': '#91A3B0',
'marengo': '#91A3B0', 'marengo': '#91A3B0',
@ -185,9 +210,13 @@ def hcolor(name: str) -> str:
'bracket': '#666666', # like the logo 'bracket': '#666666', # like the logo
'original': '#a9a9a9', 'original': '#a9a9a9',
# palette # from ``qdarkstyle`` palette
'default': DarkPalette.COLOR_BACKGROUND_NORMAL, 'default_darkest': DarkPalette.COLOR_BACKGROUND_1,
'default_light': DarkPalette.COLOR_BACKGROUND_LIGHT, 'default_dark': DarkPalette.COLOR_BACKGROUND_2,
'default': DarkPalette.COLOR_BACKGROUND_3,
'default_light': DarkPalette.COLOR_BACKGROUND_4,
'default_lightest': DarkPalette.COLOR_BACKGROUND_5,
'default_spotlight': DarkPalette.COLOR_BACKGROUND_6,
'white': '#ffffff', # for tinas and sunbathers 'white': '#ffffff', # for tinas and sunbathers

180
piker/ui/_window.py 100644
View File

@ -0,0 +1,180 @@
# 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/>.
"""
Qt main window singletons and stuff.
"""
import os
import signal
import time
from typing import Callable
from pyqtgraph import QtGui
from PyQt5 import QtCore
from PyQt5.QtGui import QLabel, QStatusBar
from ..log import get_logger
from ._style import _font_small, hcolor
log = get_logger(__name__)
class MultiStatus:
bar: QStatusBar
statuses: list[str]
def __init__(self, bar, statuses) -> None:
self.bar = bar
self.statuses = statuses
def open_status(
self,
msg: str,
) -> Callable[..., None]:
'''Add a status to the status bar and return a close callback which
when called will remove the status ``msg``.
'''
self.statuses.append(msg)
def remove_msg() -> None:
self.statuses.remove(msg)
self.render()
self.render()
return remove_msg
def render(self) -> None:
if self.statuses:
self.bar.showMessage(f'{" ".join(self.statuses)}')
else:
self.bar.clearMessage()
class MainWindow(QtGui.QMainWindow):
size = (800, 500)
title = 'piker chart (ur symbol is loading bby)'
def __init__(self, parent=None):
super().__init__(parent)
self.setMinimumSize(*self.size)
self.setWindowTitle(self.title)
self._status_bar: QStatusBar = None
self._status_label: QLabel = None
@property
def mode_label(self) -> QtGui.QLabel:
# init mode label
if not self._status_label:
self._status_label = label = QtGui.QLabel()
label.setStyleSheet(
f"QLabel {{ color : {hcolor('gunmetal')}; }}"
)
label.setTextFormat(3) # markdown
label.setFont(_font_small.font)
label.setMargin(2)
label.setAlignment(
QtCore.Qt.AlignVCenter
| QtCore.Qt.AlignRight
)
self.statusBar().addPermanentWidget(label)
label.show()
return self._status_label
def closeEvent(
self,
event: QtGui.QCloseEvent,
) -> None:
"""Cancel the root actor asap.
"""
# raising KBI seems to get intercepted by by Qt so just use the system.
os.kill(os.getpid(), signal.SIGINT)
@property
def status_bar(self) -> QStatusBar:
# style and cached the status bar on first access
if not self._status_bar:
sb = self.statusBar()
sb.setStyleSheet((
f"color : {hcolor('gunmetal')};"
f"background : {hcolor('default_dark')};"
f"font-size : {_font_small.px_size}px;"
"padding : 0px;"
# "min-height : 19px;"
# "qproperty-alignment: AlignVCenter;"
))
self.setStatusBar(sb)
self._status_bar = MultiStatus(sb, [])
return self._status_bar
def on_focus_change(
self,
old: QtGui.QWidget,
new: QtGui.QWidget,
) -> None:
log.debug(f'widget focus changed from {old} -> {new}')
if new is not None:
# cursor left window?
name = getattr(new, 'mode_name', '')
self.mode_label.setText(name)
def current_screen(self) -> QtGui.QScreen:
"""Get a frickin screen (if we can, gawd).
"""
app = QtGui.QApplication.instance()
for _ in range(3):
screen = app.screenAt(self.pos())
print('trying to access QScreen...')
if screen is None:
time.sleep(0.5)
continue
break
else:
if screen is None:
# try for the first one we can find
screen = app.screens()[0]
assert screen, "Wow Qt is dumb as shit and has no screen..."
return screen
# singleton app per actor
_qt_win: QtGui.QMainWindow = None
def main_window() -> MainWindow:
'Return the actor-global Qt window.'
global _qt_win
assert _qt_win
return _qt_win

View File

@ -309,7 +309,15 @@ async def start_order_mode(
chart: 'ChartPlotWidget', # noqa chart: 'ChartPlotWidget', # noqa
symbol: Symbol, symbol: Symbol,
brokername: str, brokername: str,
) -> None: ) -> None:
'''Activate chart-trader order mode loop:
- connect to emsd
- load existing positions
- begin order handling loop
'''
done = chart.window().status_bar.open_status('Starting order mode...')
# spawn EMS actor-service # spawn EMS actor-service
async with ( async with (
@ -335,6 +343,7 @@ async def start_order_mode(
return ohlc['index'][-1] return ohlc['index'][-1]
# Begin order-response streaming # Begin order-response streaming
done()
# this is where we receive **back** messages # this is where we receive **back** messages
# about executions **from** the EMS actor # about executions **from** the EMS actor

View File

@ -70,17 +70,12 @@ setup(
# UI # UI
'PyQt5', 'PyQt5',
'pyqtgraph', 'pyqtgraph',
'qdarkstyle==2.8.1', 'qdarkstyle >= 3.0.2',
#'kivy', see requirement.txt; using a custom branch atm
# tsdbs
'pymarketstore',
#'kivy', see requirement.txt; using a custom branch atm
# fuzzy search # fuzzy search
'fuzzywuzzy[speedup]', 'fuzzywuzzy[speedup]',
# tsdbs
'pymarketstore',
], ],
tests_require=['pytest'], tests_require=['pytest'],
python_requires=">=3.9", # literally for ``datetime.datetime.fromisoformat``... python_requires=">=3.9", # literally for ``datetime.datetime.fromisoformat``...