diff --git a/piker/ui/__init__.py b/piker/ui/__init__.py index a1006428..941b7bb6 100644 --- a/piker/ui/__init__.py +++ b/piker/ui/__init__.py @@ -5,3 +5,5 @@ import os # use the trio async loop os.environ['KIVY_EVENTLOOP'] = 'trio' +import kivy +kivy.require('1.10.0') diff --git a/piker/ui/kb.py b/piker/ui/kb.py new file mode 100644 index 00000000..ab13bd7a --- /dev/null +++ b/piker/ui/kb.py @@ -0,0 +1,139 @@ +""" +Keyboard controls +""" +import inspect +from functools import partial + +from kivy.core.window import Window +from kivy.uix.textinput import TextInput +from kivy.uix.scrollview import ScrollView + +from ..log import get_logger +log = get_logger('keyboard') + + +async def handle_input(widget, patts2funcs: dict, patt_len_limit=3): + """Handle keyboard input. + + For each character pattern-tuple in ``patts2funcs`` invoke the + corresponding mapped function(s) or coro(s). + """ + def _kb_closed(): + """Teardown handler. + """ + log.debug('Closing keyboard controls') + print('Closing keyboard controls') + kb.unbind(on_key_down=_kb_closed) + + last_patt = [] + kb = Window.request_keyboard(_kb_closed, widget, 'text') + keyq = kb.async_bind('on_key_down') + + while True: + async for kb, keycode, text, modifiers in keyq: + log.debug( + f"Keyboard input received:\n" + f"key {keycode}\ntext {text}\nmodifiers {modifiers}" + ) + code, key = keycode + if modifiers and key in modifiers: + continue + elif modifiers and key not in modifiers: + key = '-'.join(modifiers + [key]) + + # keep track of multi-key patterns + last_patt.append(key) + + func = patts2funcs.get(tuple(last_patt)) + if not func: + func = patts2funcs.get((key,)) + + if inspect.iscoroutinefunction(func): + # stop kb queue to avoid duplicate input processing + keyq.stop() + + log.debug(f'invoking kb coro func {func}') + await func() + last_patt = [] + break # trigger loop restart + + elif func: + log.debug(f'invoking kb func {func}') + func() + last_patt = [] + + if len(last_patt) > patt_len_limit: + last_patt = [] + + log.debug(f"last_patt {last_patt}") + + log.debug("Restarting keyboard handling loop") + # rebind to avoid capturing keys processed by ^ coro + keyq = kb.async_bind('on_key_down') + + log.debug("Exitting keyboard handling loop") + + +class SearchBar(TextInput): + def __init__(self, kbctls: dict, parent, **kwargs): + super(SearchBar, self).__init__( + multiline=False, + # text='/', + **kwargs + ) + self.kbctls = kbctls + self._container = parent + # indicate to ``handle_input`` that search is activated on '/' + self.kbctls.update({ + ('/',): self.handle_input + }) + + def on_text(self, instance, value): + # ``scroll.scroll_to()`` looks super awesome here for when we + # our interproc-ticker protocol + # async for item in self.async_bind('text'): + print(value) + + async def handle_input(self): + self._container.add_widget(self) # display it + self.focus = True # focus immediately (doesn't work from __init__) + # wait for to close search bar + await self.async_bind('on_text_validate').__aiter__().__anext__() + + log.debug("Closing search bar") + self._container.remove_widget(self) # stop displaying + return self.text + + +class PagerView(ScrollView): + """Pager view that adds less-like scrolling and search. + """ + def __init__(self, container, nursery, kbctls: dict = None, **kwargs): + super(PagerView, self).__init__(**kwargs) + self._container = container + self.kbctls = kbctls or {} + self.kbctls.update({ + ('g', 'g'): partial(self.move_y, 1), + ('shift-g',): partial(self.move_y, 0), + ('u',): partial(self.halfpage_y, 'u'), + ('d',): partial(self.halfpage_y, 'd'), + # ('?',): + }) + self.search = SearchBar(self.kbctls, container) + # spawn kb handler task + nursery.start_soon(handle_input, self, self.kbctls) + + def move_y(self, val): + '''Scroll in the y direction [0, 1]. + ''' + self.scroll_y = val + + def halfpage_y(self, direction): + """Scroll a half-page up or down. + """ + pxs = (self.height/2) + _, yscale = self.convert_distance_to_scroll(0, pxs) + new = self.scroll_y + (yscale * {'u': 1, 'd': -1}[direction]) + # bound to near [0, 1] to avoid "over-scrolling" + limited = max(-0.03, min(new, 1.03)) + self.scroll_y = limited diff --git a/piker/ui/watchlist.py b/piker/ui/watchlist.py index f2d8a8ff..5019b414 100644 --- a/piker/ui/watchlist.py +++ b/piker/ui/watchlist.py @@ -13,14 +13,16 @@ from kivy.uix.boxlayout import BoxLayout from kivy.uix.gridlayout import GridLayout from kivy.uix.stacklayout import StackLayout from kivy.uix.button import Button -from kivy.uix.scrollview import ScrollView from kivy.lang import Builder from kivy import utils from kivy.app import async_runTouchApp +from kivy.core.window import Window from ..calc import humanize, percent_change from ..log import get_logger +from .kb import PagerView + log = get_logger('watchlist') @@ -86,6 +88,11 @@ _kv = (f''' # row_force_default: True # row_default_height: 75 outline_color: [.7]*4 + + + size_hint: 1, 0.03 + font_size: 25 + background_color: [0.13]*3 + [1] ''') @@ -340,6 +347,7 @@ class TickerTable(GridLayout): self.symbols2rows = {} self.sort_key = sort_key self.quote_cache = quote_cache + self.row_filter = lambda item: item # for tracking last clicked column header cell self.last_clicked_col_cell = None @@ -352,13 +360,18 @@ class TickerTable(GridLayout): self.add_widget(row) return row - def render_rows(self, pairs: (dict, Row), sort_key: str = None): + def render_rows( + self, pairs: (dict, Row), sort_key: str = None, row_filter=None, + ): """Sort and render all rows on the ticker grid from ``pairs``. """ self.clear_widgets() sort_key = sort_key or self.sort_key - for data, row in reversed( - sorted(pairs.values(), key=lambda item: item[0][sort_key]) + for data, row in filter( + row_filter or self.row_filter, + reversed( + sorted(pairs.values(), key=lambda item: item[0][sort_key]) + ) ): self.add_widget(row) # row append @@ -469,13 +482,16 @@ async def _async_main(name, watchlists, brokermod): qtconvert(quote, symbol_data=sd)[0] for quote in pkts] # build out UI + Window.set_title(f"watchlist: {name}\t(press ? for help)") Builder.load_string(_kv) - root = BoxLayout(orientation='vertical', padding=5, spacing=5) + # anchor = AnchorLayout(anchor_x='right', anchor_y='bottom') + box = BoxLayout(orientation='vertical', padding=5, spacing=5) + # anchor.add_widget(box) header = header_row( first_quotes[0].keys(), size_hint=(1, None), ) - root.add_widget(header) + box.add_widget(header) grid = ticker_table( first_quotes, size_hint=(1, None), @@ -488,17 +504,19 @@ async def _async_main(name, watchlists, brokermod): sort_cell.bold = sort_cell.underline = True grid.last_clicked_col_cell = sort_cell - # set up a scroll view for large ticker lists + # set up a pager view for large ticker lists grid.bind(minimum_height=grid.setter('height')) - scroll = ScrollView() - scroll.add_widget(grid) - root.add_widget(scroll) + pager = PagerView(box, nursery) + pager.add_widget(grid) + box.add_widget(pager) widgets = { + # 'anchor': anchor, + 'root': box, 'grid': grid, - 'root': root, + 'box': box, 'header': header, - 'scroll': scroll, + 'pager': pager, } nursery.start_soon(run_kivy, widgets['root'], nursery) nursery.start_soon(update_quotes, widgets, queue, sd, pkts)