Tabular kivy UI improvements

`Row`:
- `no_cell`: support a list of keys for which no cells will be created
- allow passing in a `cell_type` at instantiation

`TickerTable`:
- keep track of rendered rows via a private `_rendered` set
- don't create rows inside `append_row()` expect caller to do it
- never render already rendered widgets in `render_rows()`

Miscellaneous:
- generalize `update_quotes()` to not be tied to specific quote fields
  and allow passing in a quote `formatter()` func
- don't bother creating a nursery block until necessary in main
- more commenting
kivy_mainline_and_py3.8
Tyler Goodlet 2018-12-10 01:51:49 -05:00
parent 20778b02b5
commit 8647216b75
1 changed files with 151 additions and 103 deletions

View File

@ -7,7 +7,7 @@ Launch with ``piker monitor <watchlist name>``.
""" """
from itertools import chain from itertools import chain
from types import ModuleType, AsyncGeneratorType from types import ModuleType, AsyncGeneratorType
from typing import List from typing import List, Callable, Dict
import trio import trio
import tractor import tractor
@ -19,7 +19,6 @@ from kivy.lang import Builder
from kivy import utils from kivy import utils
from kivy.app import async_runTouchApp from kivy.app import async_runTouchApp
from kivy.core.window import Window from kivy.core.window import Window
from async_generator import aclosing
from ..log import get_logger from ..log import get_logger
from .pager import PagerView from .pager import PagerView
@ -126,10 +125,11 @@ _kv = (f'''
# row higlighting on mouse over # row higlighting on mouse over
Color: Color:
rgba: {_i3_rgba} rgba: {_i3_rgba}
RoundedRectangle: # RoundedRectangle:
Rectangle:
size: self.width, self.height if self.hovered else 1 size: self.width, self.height if self.hovered else 1
pos: self.pos pos: self.pos
radius: (10,) # radius: (0,)
@ -148,9 +148,10 @@ class Cell(Button):
``key`` is the column name index value. ``key`` is the column name index value.
""" """
def __init__(self, key=None, **kwargs): def __init__(self, key=None, is_header=False, **kwargs):
super(Cell, self).__init__(**kwargs) super(Cell, self).__init__(**kwargs)
self.key = key self.key = key
self.is_header = is_header
class HeaderCell(Cell): class HeaderCell(Cell):
@ -178,6 +179,8 @@ class HeaderCell(Cell):
# sort and render the rows immediately # sort and render the rows immediately
self.row.table.render_rows(table.quote_cache) self.row.table.render_rows(table.quote_cache)
# TODO: make this some kind of small geometry instead
# (maybe like how trading view does it).
# allow highlighting of row headers for tracking # allow highlighting of row headers for tracking
elif self.is_header: elif self.is_header:
if self.background_color == self.color: if self.background_color == self.color:
@ -227,7 +230,7 @@ class BidAskLayout(StackLayout):
self.row = None self.row = None
def get_cell(self, key): def get_cell(self, key):
return self._keys2cells[key] return self._keys2cells.get(key)
@property @property
def row(self): def row(self):
@ -246,13 +249,16 @@ class BidAskLayout(StackLayout):
class Row(GridLayout, HoverBehavior): class Row(GridLayout, HoverBehavior):
"""A grid for displaying a row of ticker quote data. """A grid for displaying a row of ticker quote data.
The row fields can be updated using the ``fields`` property which will in
turn adjust the text color of the values based on content changes.
""" """
def __init__( def __init__(
self, record, headers=(), bidasks=None, table=None, self,
record,
headers=(),
no_cell=(),
bidasks=None,
table=None,
is_header=False, is_header=False,
cell_type=None,
**kwargs **kwargs
): ):
super(Row, self).__init__(cols=len(record), **kwargs) super(Row, self).__init__(cols=len(record), **kwargs)
@ -260,11 +266,13 @@ class Row(GridLayout, HoverBehavior):
self._last_record = record self._last_record = record
self.table = table self.table = table
self.is_header = is_header self.is_header = is_header
self._cell_type = cell_type
self.widget = self
# selection state # Create `BidAskCells` first.
self.mouse_over = False # bid/ask cells are just 3 cells grouped in a
# ``BidAskLayout`` which just stacks the parent cell
# create `BidAskCells` first # on top of 2 children.
layouts = {} layouts = {}
bidasks = bidasks or {} bidasks = bidasks or {}
ba_cells = {} ba_cells = {}
@ -291,20 +299,20 @@ class Row(GridLayout, HoverBehavior):
elif key in children_flat: elif key in children_flat:
# these cells have already been added to the `BidAskLayout` # these cells have already been added to the `BidAskLayout`
continue continue
else: elif key not in no_cell:
cell = self._append_cell(val, key, header=header) cell = self._append_cell(val, key, header=header)
cell.key = key cell.key = key
self._cell_widgets[key] = cell self._cell_widgets[key] = cell
def get_cell(self, key): def get_cell(self, key):
return self._cell_widgets[key] return self._cell_widgets.get(key)
def _append_cell(self, text, key, header=False): def _append_cell(self, text, key, header=False):
if not len(self._cell_widgets) < self.cols: if not len(self._cell_widgets) < self.cols:
raise ValueError(f"Can not append more then {self.cols} cells") raise ValueError(f"Can not append more then {self.cols} cells")
# header cells just have a different colour # header cells just have a different colour
celltype = HeaderCell if header else Cell celltype = self._cell_type or (HeaderCell if header else Cell)
cell = celltype(text=str(text), key=key) cell = celltype(text=str(text), key=key)
cell.is_header = header cell.is_header = header
cell.row = self cell.row = self
@ -312,7 +320,8 @@ class Row(GridLayout, HoverBehavior):
return cell return cell
def update(self, record, displayable): def update(self, record, displayable):
"""Update this row's cells with new values from a quote ``record``. """Update this row's cells with new values from a quote
``record``.
Return all cells that changed in a ``dict``. Return all cells that changed in a ``dict``.
""" """
@ -331,6 +340,9 @@ class Row(GridLayout, HoverBehavior):
color = gray color = gray
cell = self.get_cell(key) cell = self.get_cell(key)
# some displayable fields might have specifically
# not had cells created as set in the `no_cell` attr
if cell is not None:
cell.text = str(displayable[key]) cell.text = str(displayable[key])
cell.color = color cell.color = color
if color != gray: if color != gray:
@ -359,40 +371,53 @@ class Row(GridLayout, HoverBehavior):
class TickerTable(GridLayout): class TickerTable(GridLayout):
"""A grid for displaying ticker quote records as a table. """A grid for displaying ticker quote records as a table.
""" """
def __init__(self, sort_key='%', quote_cache={}, **kwargs): def __init__(self, sort_key='%', **kwargs):
super(TickerTable, self).__init__(**kwargs) super(TickerTable, self).__init__(**kwargs)
self.symbols2rows = {} self.symbols2rows = {}
self.sort_key = sort_key self.sort_key = sort_key
self.quote_cache = quote_cache self.quote_cache = {}
self.row_filter = lambda item: item self.row_filter = lambda item: item
# for tracking last clicked column header cell # for tracking last clicked column header cell
self.last_clicked_col_cell = None self.last_clicked_col_cell = None
self._last_row_toggle = 0 self._last_row_toggle = 0
self._rendered = set()
def append_row(self, record, bidasks=None): def append_row(self, key, row):
"""Append a `Row` of `Cell` objects to this table. """Append a `Row` of `Cell` objects to this table.
""" """
row = Row(record, headers=('symbol',), bidasks=bidasks, table=self)
# store ref to each row # store ref to each row
self.symbols2rows[row._last_record['symbol']] = row self.symbols2rows[key] = row
self.add_widget(row) self.add_widget(row)
return row return row
def render_rows( def render_rows(
self, pairs: {str: (dict, Row)}, sort_key: str = None, self,
pairs: Dict[str, Row],
sort_key: str = None,
row_filter=None, row_filter=None,
): ):
"""Sort and render all rows on the ticker grid from ``pairs``. """Sort and render all rows on the ticker grid from ``pairs``.
""" """
self.clear_widgets() self.clear_widgets()
self._rendered.clear()
sort_key = sort_key or self.sort_key sort_key = sort_key or self.sort_key
for data, row in filter( # TODO: intead of constantly re-rendering on every
# change do a binary search insert using ``bisect.insort()``
for row in filter(
row_filter or self.row_filter, row_filter or self.row_filter,
reversed( reversed(
sorted(pairs.values(), key=lambda item: item[0][sort_key]) sorted(
pairs.values(),
key=lambda row: row._last_record[sort_key]
)
) )
): ):
self.add_widget(row) # row append widget = row.widget
if widget not in self._rendered:
self.add_widget(widget) # row append
self._rendered.add(widget)
else:
log.debug(f"Skipping adding widget {widget}")
def ticker_search(self, patt): def ticker_search(self, patt):
"""Return sequence of matches when pattern ``patt`` is in a """Return sequence of matches when pattern ``patt`` is in a
@ -402,6 +427,9 @@ class TickerTable(GridLayout):
if patt in symbol: if patt in symbol:
yield symbol, row yield symbol, row
def get_row(self, symbol: str) -> Row:
return self.symbols2rows[symbol]
def search(self, patt): def search(self, patt):
"""Search bar api compat. """Search bar api compat.
""" """
@ -410,7 +438,7 @@ class TickerTable(GridLayout):
async def update_quotes( async def update_quotes(
nursery: trio._core._run.Nursery, nursery: trio._core._run.Nursery,
brokermod: ModuleType, formatter: Callable,
widgets: dict, widgets: dict,
agen: AsyncGeneratorType, agen: AsyncGeneratorType,
symbol_data: dict, symbol_data: dict,
@ -426,21 +454,24 @@ async def update_quotes(
for cell in cells: for cell in cells:
cell.background_color = _black_rgba cell.background_color = _black_rgba
def color_row(row, data, cells): def color_row(row, record, cells):
hdrcell = row.get_cell('symbol') hdrcell = row.get_cell('symbol')
chngcell = row.get_cell('%') chngcell = row.get_cell('%')
# determine daily change color # determine daily change color
daychange = float(data['%']) color = colorcode('gray')
percent_change = record.get('%')
if percent_change:
daychange = float(record['%'])
if daychange < 0.: if daychange < 0.:
color = colorcode('red2') color = colorcode('red2')
elif daychange > 0.: elif daychange > 0.:
color = colorcode('forestgreen') color = colorcode('forestgreen')
else:
color = colorcode('gray')
# update row header and '%' cell text color # update row header and '%' cell text color
chngcell.color = hdrcell.color = color if chngcell:
chngcell.color = color
hdrcell.color = color
# if the cell has been "highlighted" make sure to change its color # if the cell has been "highlighted" make sure to change its color
if hdrcell.background_color != [0]*4: if hdrcell.background_color != [0]*4:
hdrcell.background_color = color hdrcell.background_color = color
@ -483,12 +514,12 @@ async def update_quotes(
# initial coloring # initial coloring
for sym, quote in first_quotes.items(): for sym, quote in first_quotes.items():
row = table.symbols2rows[sym] row = table.get_row(sym)
record, displayable = brokermod.format_quote( record, displayable = formatter(
quote, symbol_data=symbol_data) quote, symbol_data=symbol_data)
row.update(record, displayable) row.update(record, displayable)
color_row(row, record, {}) color_row(row, record, {})
cache[sym] = (record, row) cache[sym] = row
# render all rows once up front # render all rows once up front
table.render_rows(cache) table.render_rows(cache)
@ -496,12 +527,12 @@ async def update_quotes(
# real-time cell update loop # real-time cell update loop
async for quotes in agen: # new quotes data only async for quotes in agen: # new quotes data only
for symbol, quote in quotes.items(): for symbol, quote in quotes.items():
record, displayable = brokermod.format_quote( record, displayable = formatter(
quote, symbol_data=symbol_data) quote, symbol_data=symbol_data)
row = table.symbols2rows[symbol] row = table.get_row(symbol)
cache[symbol] = (record, row)
cells = row.update(record, displayable) cells = row.update(record, displayable)
color_row(row, record, cells) color_row(row, record, cells)
cache[symbol] = row
table.render_rows(cache) table.render_rows(cache)
log.debug("Waiting on quotes") log.debug("Waiting on quotes")
@ -528,6 +559,9 @@ async def _async_main(
"piker.brokers.data", 'stream_from_file', "piker.brokers.data", 'stream_from_file',
filename=test filename=test
) )
# TODO: need a set of test packets to make this work
# seriously fu QT
# sd = {}
else: else:
# start live streaming from broker daemon # start live streaming from broker daemon
quote_gen = await portal.run( quote_gen = await portal.run(
@ -540,17 +574,15 @@ async def _async_main(
"piker.brokers.data", 'symbol_data', "piker.brokers.data", 'symbol_data',
broker=brokermod.name, tickers=tickers) broker=brokermod.name, tickers=tickers)
async with trio.open_nursery() as nursery:
# get first quotes response # get first quotes response
log.debug("Waiting on first quote...") log.debug("Waiting on first quote...")
quotes = await quote_gen.__anext__() quotes = await quote_gen.__anext__()
first_quotes = [ first_quotes = [
brokermod.format_quote(quote, symbol_data=sd)[0] brokermod.format_stock_quote(quote, symbol_data=sd)[0]
for quote in quotes.values()] for quote in quotes.values()]
if first_quotes[0].get('last') is None: if first_quotes[0].get('last') is None:
log.error("Broker API is down temporarily") log.error("Broker API is down temporarily")
nursery.cancel_scope.cancel()
return return
# build out UI # build out UI
@ -560,7 +592,7 @@ async def _async_main(
# define bid-ask "stacked" cells # define bid-ask "stacked" cells
# (TODO: needs some rethinking and renaming for sure) # (TODO: needs some rethinking and renaming for sure)
bidasks = brokermod._bidasks bidasks = brokermod._stock_bidasks
# add header row # add header row
headers = first_quotes[0].keys() headers = first_quotes[0].keys()
@ -579,7 +611,11 @@ async def _async_main(
size_hint=(1, None), size_hint=(1, None),
) )
for ticker_record in first_quotes: for ticker_record in first_quotes:
table.append_row(ticker_record, bidasks=bidasks) table.append_row(
ticker_record['symbol'],
Row(ticker_record, headers=('symbol',), bidasks=bidasks, table=table)
)
# associate the col headers row with the ticker table even though # associate the col headers row with the ticker table even though
# they're technically wrapped separately in containing BoxLayout # they're technically wrapped separately in containing BoxLayout
header.table = table header.table = table
@ -591,11 +627,16 @@ async def _async_main(
# set up a pager view for large ticker lists # set up a pager view for large ticker lists
table.bind(minimum_height=table.setter('height')) table.bind(minimum_height=table.setter('height'))
pager = PagerView(box, table, nursery)
async with trio.open_nursery() as nursery:
pager = PagerView(
container=box,
contained=table,
nursery=nursery
)
box.add_widget(pager) box.add_widget(pager)
widgets = { widgets = {
# 'anchor': anchor,
'root': box, 'root': box,
'table': table, 'table': table,
'box': box, 'box': box,
@ -603,7 +644,14 @@ async def _async_main(
'pager': pager, 'pager': pager,
} }
nursery.start_soon( nursery.start_soon(
update_quotes, nursery, brokermod, widgets, quote_gen, sd, quotes) update_quotes,
nursery,
brokermod.format_stock_quote,
widgets,
quote_gen,
sd,
quotes
)
try: try:
# Trio-kivy entry point. # Trio-kivy entry point.