Use binary search (bisection) to sort table rows
This is an optimization to improve performance when the UI is fed real time data. Instead of resorting all rows on every quote update, only re-render when the sort key appears in the quote data, and further, only resort rows which are changed using bisection based widget insertion to avoid having `kivy` re-add widgets (and thus re-render graphics) more often than absolutely necessary.kivy_mainline_and_py3.8
parent
721e3803b2
commit
07eb8ae5e0
|
@ -8,6 +8,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, Callable, Dict
|
from typing import List, Callable, Dict
|
||||||
|
from bisect import bisect
|
||||||
|
|
||||||
import trio
|
import trio
|
||||||
import tractor
|
import tractor
|
||||||
|
@ -19,6 +20,7 @@ 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 kivy.properties import BooleanProperty
|
||||||
|
|
||||||
from ..log import get_logger
|
from ..log import get_logger
|
||||||
from .pager import PagerView
|
from .pager import PagerView
|
||||||
|
@ -69,8 +71,10 @@ _kv = (f'''
|
||||||
size: self.texture_size
|
size: self.texture_size
|
||||||
# color: {colorcode('gray')}
|
# color: {colorcode('gray')}
|
||||||
# font_color: {colorcode('gray')}
|
# font_color: {colorcode('gray')}
|
||||||
font_name: 'Roboto-Regular'
|
# font_name: 'Hack-Regular'
|
||||||
background_color: [0]*4 # by default transparent; use row color
|
# by default transparent; use row color
|
||||||
|
# if `highlight` is set use i3
|
||||||
|
background_color: {_i3_rgba} if self.click_toggle else [0]*4
|
||||||
# background_color: {_cell_rgba}
|
# background_color: {_cell_rgba}
|
||||||
# spacing: 0, 0
|
# spacing: 0, 0
|
||||||
# padding: [0]*4
|
# padding: [0]*4
|
||||||
|
@ -148,6 +152,8 @@ class Cell(Button):
|
||||||
|
|
||||||
``key`` is the column name index value.
|
``key`` is the column name index value.
|
||||||
"""
|
"""
|
||||||
|
click_toggle = BooleanProperty(False)
|
||||||
|
|
||||||
def __init__(self, key=None, is_header=False, **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
|
||||||
|
@ -177,7 +183,7 @@ class HeaderCell(Cell):
|
||||||
# mark this cell as the last selected
|
# mark this cell as the last selected
|
||||||
table.last_clicked_col_cell = self
|
table.last_clicked_col_cell = self
|
||||||
# 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.symbols2rows.values())
|
||||||
|
|
||||||
# TODO: make this some kind of small geometry instead
|
# TODO: make this some kind of small geometry instead
|
||||||
# (maybe like how trading view does it).
|
# (maybe like how trading view does it).
|
||||||
|
@ -310,6 +316,9 @@ class Row(GridLayout, HoverBehavior):
|
||||||
def get_cell(self, key):
|
def get_cell(self, key):
|
||||||
return self._cell_widgets.get(key)
|
return self._cell_widgets.get(key)
|
||||||
|
|
||||||
|
def get_field(self, key):
|
||||||
|
return self._last_record[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")
|
||||||
|
@ -378,12 +387,10 @@ class TickerTable(GridLayout):
|
||||||
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 = {}
|
|
||||||
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._symbols2index = {}
|
||||||
self._rendered = set()
|
self._sorted = []
|
||||||
|
|
||||||
def append_row(self, key, row):
|
def append_row(self, key, row):
|
||||||
"""Append a `Row` of `Cell` objects to this table.
|
"""Append a `Row` of `Cell` objects to this table.
|
||||||
|
@ -391,36 +398,51 @@ class TickerTable(GridLayout):
|
||||||
# store ref to each row
|
# store ref to each row
|
||||||
self.symbols2rows[key] = row
|
self.symbols2rows[key] = row
|
||||||
self.add_widget(row)
|
self.add_widget(row)
|
||||||
|
self._sorted.append(row)
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
self.clear_widgets()
|
||||||
|
self._sorted.clear()
|
||||||
|
|
||||||
def render_rows(
|
def render_rows(
|
||||||
self,
|
self,
|
||||||
pairs: Dict[str, Row],
|
changed: set,
|
||||||
sort_key: str = None,
|
sort_key: str = 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 ``syms2rows``.
|
||||||
"""
|
"""
|
||||||
self.clear_widgets()
|
|
||||||
self._rendered.clear()
|
|
||||||
sort_key = sort_key or self.sort_key
|
sort_key = sort_key or self.sort_key
|
||||||
# TODO: intead of constantly re-rendering on every
|
key_row_pairs = list(sorted(
|
||||||
# change do a binary search insert using ``bisect.insort()``
|
[(row.get_field(sort_key), row) for row in self._sorted],
|
||||||
for row in filter(
|
key=lambda item: item[0],
|
||||||
row_filter or self.row_filter,
|
))
|
||||||
reversed(
|
if key_row_pairs:
|
||||||
sorted(
|
sorted_keys, sorted_rows = zip(*key_row_pairs)
|
||||||
pairs.values(),
|
sorted_keys, sorted_rows = list(sorted_keys), list(sorted_rows)
|
||||||
key=lambda row: row._last_record[sort_key]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
):
|
|
||||||
widget = row.widget
|
|
||||||
if widget not in self._rendered:
|
|
||||||
self.add_widget(widget) # row append
|
|
||||||
self._rendered.add(widget)
|
|
||||||
else:
|
else:
|
||||||
log.debug(f"Skipping adding widget {widget}")
|
sorted_keys, sorted_rows = [], []
|
||||||
|
|
||||||
|
# now remove and re-insert any rows that need to be shuffled
|
||||||
|
# due to new a new field change
|
||||||
|
for row in changed:
|
||||||
|
try:
|
||||||
|
old_index = sorted_rows.index(row)
|
||||||
|
except ValueError:
|
||||||
|
# row is not yet added so nothing to remove
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
del sorted_rows[old_index]
|
||||||
|
del sorted_keys[old_index]
|
||||||
|
self._sorted.remove(row)
|
||||||
|
self.remove_widget(row)
|
||||||
|
|
||||||
|
for row in changed:
|
||||||
|
key = row.get_field(sort_key)
|
||||||
|
index = bisect(sorted_keys, key)
|
||||||
|
sorted_keys.insert(index, key)
|
||||||
|
self._sorted.insert(index, row)
|
||||||
|
self.add_widget(row, index=index)
|
||||||
|
|
||||||
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
|
||||||
|
@ -514,36 +536,41 @@ async def update_quotes(
|
||||||
# revert flash state momentarily
|
# revert flash state momentarily
|
||||||
nursery.start_soon(revert_cells_color, unflash)
|
nursery.start_soon(revert_cells_color, unflash)
|
||||||
|
|
||||||
cache = {}
|
|
||||||
table.quote_cache = cache
|
|
||||||
|
|
||||||
# initial coloring
|
# initial coloring
|
||||||
|
to_sort = set()
|
||||||
for sym, quote in first_quotes.items():
|
for sym, quote in first_quotes.items():
|
||||||
row = table.get_row(sym)
|
row = table.get_row(sym)
|
||||||
record, displayable = formatter(
|
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] = row
|
to_sort.add(row.widget)
|
||||||
|
|
||||||
|
table.render_rows(to_sort)
|
||||||
|
|
||||||
log.debug("Finished initializing update loop")
|
log.debug("Finished initializing update loop")
|
||||||
task_status.started()
|
task_status.started()
|
||||||
# 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
|
||||||
|
to_sort = set()
|
||||||
for symbol, quote in quotes.items():
|
for symbol, quote in quotes.items():
|
||||||
record, displayable = formatter(
|
record, displayable = formatter(
|
||||||
quote, symbol_data=symbol_data)
|
quote, symbol_data=symbol_data)
|
||||||
row = table.get_row(symbol)
|
row = table.get_row(symbol)
|
||||||
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)
|
if table.sort_key in record:
|
||||||
|
to_sort.add(row.widget)
|
||||||
|
|
||||||
|
if to_sort:
|
||||||
|
table.render_rows(to_sort)
|
||||||
|
|
||||||
log.debug("Waiting on quotes")
|
log.debug("Waiting on quotes")
|
||||||
|
|
||||||
log.warn("Data feed connection dropped")
|
log.warn("Data feed connection dropped")
|
||||||
# XXX: if we're cancelled this should never get called
|
# XXX: if we're cancelled this should never get called
|
||||||
nursery.cancel_scope.cancel()
|
# nursery.cancel_scope.cancel()
|
||||||
|
|
||||||
|
|
||||||
async def _async_main(
|
async def _async_main(
|
||||||
|
@ -638,6 +665,7 @@ 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'))
|
||||||
|
|
||||||
|
try:
|
||||||
async with trio.open_nursery() as nursery:
|
async with trio.open_nursery() as nursery:
|
||||||
pager = PagerView(
|
pager = PagerView(
|
||||||
container=box,
|
container=box,
|
||||||
|
@ -663,11 +691,11 @@ async def _async_main(
|
||||||
quotes
|
quotes
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
|
||||||
# Trio-kivy entry point.
|
# Trio-kivy entry point.
|
||||||
await async_runTouchApp(widgets['root']) # run kivy
|
await async_runTouchApp(widgets['root']) # run kivy
|
||||||
finally:
|
|
||||||
# cancel aysnc gen call
|
|
||||||
await quote_gen.aclose()
|
|
||||||
# cancel GUI update task
|
# cancel GUI update task
|
||||||
nursery.cancel_scope.cancel()
|
nursery.cancel_scope.cancel()
|
||||||
|
finally:
|
||||||
|
with trio.open_cancel_scope(shield=True):
|
||||||
|
# cancel aysnc gen call
|
||||||
|
await quote_gen.aclose()
|
||||||
|
|
Loading…
Reference in New Issue