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 types import ModuleType, AsyncGeneratorType
from typing import List
from typing import List, Callable, Dict
import trio
import tractor
@ -19,7 +19,6 @@ from kivy.lang import Builder
from kivy import utils
from kivy.app import async_runTouchApp
from kivy.core.window import Window
from async_generator import aclosing
from ..log import get_logger
from .pager import PagerView
@ -126,10 +125,11 @@ _kv = (f'''
# row higlighting on mouse over
Color:
rgba: {_i3_rgba}
RoundedRectangle:
# RoundedRectangle:
Rectangle:
size: self.width, self.height if self.hovered else 1
pos: self.pos
radius: (10,)
# radius: (0,)
@ -148,9 +148,10 @@ class Cell(Button):
``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)
self.key = key
self.is_header = is_header
class HeaderCell(Cell):
@ -178,6 +179,8 @@ class HeaderCell(Cell):
# sort and render the rows immediately
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
elif self.is_header:
if self.background_color == self.color:
@ -227,7 +230,7 @@ class BidAskLayout(StackLayout):
self.row = None
def get_cell(self, key):
return self._keys2cells[key]
return self._keys2cells.get(key)
@property
def row(self):
@ -246,13 +249,16 @@ class BidAskLayout(StackLayout):
class Row(GridLayout, HoverBehavior):
"""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__(
self, record, headers=(), bidasks=None, table=None,
self,
record,
headers=(),
no_cell=(),
bidasks=None,
table=None,
is_header=False,
cell_type=None,
**kwargs
):
super(Row, self).__init__(cols=len(record), **kwargs)
@ -260,11 +266,13 @@ class Row(GridLayout, HoverBehavior):
self._last_record = record
self.table = table
self.is_header = is_header
self._cell_type = cell_type
self.widget = self
# selection state
self.mouse_over = False
# create `BidAskCells` first
# Create `BidAskCells` first.
# bid/ask cells are just 3 cells grouped in a
# ``BidAskLayout`` which just stacks the parent cell
# on top of 2 children.
layouts = {}
bidasks = bidasks or {}
ba_cells = {}
@ -291,20 +299,20 @@ class Row(GridLayout, HoverBehavior):
elif key in children_flat:
# these cells have already been added to the `BidAskLayout`
continue
else:
elif key not in no_cell:
cell = self._append_cell(val, key, header=header)
cell.key = key
self._cell_widgets[key] = cell
def get_cell(self, key):
return self._cell_widgets[key]
return self._cell_widgets.get(key)
def _append_cell(self, text, key, header=False):
if not len(self._cell_widgets) < self.cols:
raise ValueError(f"Can not append more then {self.cols} cells")
# 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.is_header = header
cell.row = self
@ -312,7 +320,8 @@ class Row(GridLayout, HoverBehavior):
return cell
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``.
"""
@ -331,10 +340,13 @@ class Row(GridLayout, HoverBehavior):
color = gray
cell = self.get_cell(key)
cell.text = str(displayable[key])
cell.color = color
if color != gray:
cells[key] = cell
# 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.color = color
if color != gray:
cells[key] = cell
self._last_record = record
return cells
@ -359,40 +371,53 @@ class Row(GridLayout, HoverBehavior):
class TickerTable(GridLayout):
"""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)
self.symbols2rows = {}
self.sort_key = sort_key
self.quote_cache = quote_cache
self.quote_cache = {}
self.row_filter = lambda item: item
# for tracking last clicked column header cell
self.last_clicked_col_cell = None
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.
"""
row = Row(record, headers=('symbol',), bidasks=bidasks, table=self)
# store ref to each row
self.symbols2rows[row._last_record['symbol']] = row
self.symbols2rows[key] = row
self.add_widget(row)
return row
def render_rows(
self, pairs: {str: (dict, Row)}, sort_key: str = None,
row_filter=None,
self,
pairs: Dict[str, Row],
sort_key: str = None,
row_filter=None,
):
"""Sort and render all rows on the ticker grid from ``pairs``.
"""
self.clear_widgets()
self._rendered.clear()
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,
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):
"""Return sequence of matches when pattern ``patt`` is in a
@ -402,6 +427,9 @@ class TickerTable(GridLayout):
if patt in symbol:
yield symbol, row
def get_row(self, symbol: str) -> Row:
return self.symbols2rows[symbol]
def search(self, patt):
"""Search bar api compat.
"""
@ -410,7 +438,7 @@ class TickerTable(GridLayout):
async def update_quotes(
nursery: trio._core._run.Nursery,
brokermod: ModuleType,
formatter: Callable,
widgets: dict,
agen: AsyncGeneratorType,
symbol_data: dict,
@ -426,24 +454,27 @@ async def update_quotes(
for cell in cells:
cell.background_color = _black_rgba
def color_row(row, data, cells):
def color_row(row, record, cells):
hdrcell = row.get_cell('symbol')
chngcell = row.get_cell('%')
# determine daily change color
daychange = float(data['%'])
if daychange < 0.:
color = colorcode('red2')
elif daychange > 0.:
color = colorcode('forestgreen')
else:
color = colorcode('gray')
color = colorcode('gray')
percent_change = record.get('%')
if percent_change:
daychange = float(record['%'])
if daychange < 0.:
color = colorcode('red2')
elif daychange > 0.:
color = colorcode('forestgreen')
# update row header and '%' cell text color
chngcell.color = hdrcell.color = color
# if the cell has been "highlighted" make sure to change its color
if hdrcell.background_color != [0]*4:
hdrcell.background_color = color
if chngcell:
chngcell.color = color
hdrcell.color = color
# if the cell has been "highlighted" make sure to change its color
if hdrcell.background_color != [0]*4:
hdrcell.background_color = color
# briefly highlight bg of certain cells on each trade execution
unflash = set()
@ -483,12 +514,12 @@ async def update_quotes(
# initial coloring
for sym, quote in first_quotes.items():
row = table.symbols2rows[sym]
record, displayable = brokermod.format_quote(
row = table.get_row(sym)
record, displayable = formatter(
quote, symbol_data=symbol_data)
row.update(record, displayable)
color_row(row, record, {})
cache[sym] = (record, row)
cache[sym] = row
# render all rows once up front
table.render_rows(cache)
@ -496,12 +527,12 @@ async def update_quotes(
# real-time cell update loop
async for quotes in agen: # new quotes data only
for symbol, quote in quotes.items():
record, displayable = brokermod.format_quote(
record, displayable = formatter(
quote, symbol_data=symbol_data)
row = table.symbols2rows[symbol]
cache[symbol] = (record, row)
row = table.get_row(symbol)
cells = row.update(record, displayable)
color_row(row, record, cells)
cache[symbol] = row
table.render_rows(cache)
log.debug("Waiting on quotes")
@ -528,6 +559,9 @@ async def _async_main(
"piker.brokers.data", 'stream_from_file',
filename=test
)
# TODO: need a set of test packets to make this work
# seriously fu QT
# sd = {}
else:
# start live streaming from broker daemon
quote_gen = await portal.run(
@ -540,62 +574,69 @@ async def _async_main(
"piker.brokers.data", 'symbol_data',
broker=brokermod.name, tickers=tickers)
# get first quotes response
log.debug("Waiting on first quote...")
quotes = await quote_gen.__anext__()
first_quotes = [
brokermod.format_stock_quote(quote, symbol_data=sd)[0]
for quote in quotes.values()]
if first_quotes[0].get('last') is None:
log.error("Broker API is down temporarily")
return
# build out UI
Window.set_title(f"monitor: {name}\t(press ? for help)")
Builder.load_string(_kv)
box = BoxLayout(orientation='vertical', spacing=0)
# define bid-ask "stacked" cells
# (TODO: needs some rethinking and renaming for sure)
bidasks = brokermod._stock_bidasks
# add header row
headers = first_quotes[0].keys()
header = Row(
{key: key for key in headers},
headers=headers,
bidasks=bidasks,
is_header=True,
size_hint=(1, None),
)
box.add_widget(header)
# build table
table = TickerTable(
cols=1,
size_hint=(1, None),
)
for ticker_record in first_quotes:
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
# they're technically wrapped separately in containing BoxLayout
header.table = table
# mark the initial sorted column header as bold and underlined
sort_cell = header.get_cell(table.sort_key)
sort_cell.bold = sort_cell.underline = True
table.last_clicked_col_cell = sort_cell
# set up a pager view for large ticker lists
table.bind(minimum_height=table.setter('height'))
async with trio.open_nursery() as nursery:
# get first quotes response
log.debug("Waiting on first quote...")
quotes = await quote_gen.__anext__()
first_quotes = [
brokermod.format_quote(quote, symbol_data=sd)[0]
for quote in quotes.values()]
if first_quotes[0].get('last') is None:
log.error("Broker API is down temporarily")
nursery.cancel_scope.cancel()
return
# build out UI
Window.set_title(f"monitor: {name}\t(press ? for help)")
Builder.load_string(_kv)
box = BoxLayout(orientation='vertical', spacing=0)
# define bid-ask "stacked" cells
# (TODO: needs some rethinking and renaming for sure)
bidasks = brokermod._bidasks
# add header row
headers = first_quotes[0].keys()
header = Row(
{key: key for key in headers},
headers=headers,
bidasks=bidasks,
is_header=True,
size_hint=(1, None),
pager = PagerView(
container=box,
contained=table,
nursery=nursery
)
box.add_widget(header)
# build table
table = TickerTable(
cols=1,
size_hint=(1, None),
)
for ticker_record in first_quotes:
table.append_row(ticker_record, bidasks=bidasks)
# associate the col headers row with the ticker table even though
# they're technically wrapped separately in containing BoxLayout
header.table = table
# mark the initial sorted column header as bold and underlined
sort_cell = header.get_cell(table.sort_key)
sort_cell.bold = sort_cell.underline = True
table.last_clicked_col_cell = sort_cell
# set up a pager view for large ticker lists
table.bind(minimum_height=table.setter('height'))
pager = PagerView(box, table, nursery)
box.add_widget(pager)
widgets = {
# 'anchor': anchor,
'root': box,
'table': table,
'box': box,
@ -603,7 +644,14 @@ async def _async_main(
'pager': pager,
}
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:
# Trio-kivy entry point.