489 lines
15 KiB
Python
489 lines
15 KiB
Python
# (c) Copyright 2018 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
|
#
|
|
# menu.py - Implement an interactive menu system.
|
|
#
|
|
import gc
|
|
from ux import PressRelease, the_ux
|
|
from uasyncio import sleep_ms
|
|
from charcodes import (KEY_UP, KEY_DOWN, KEY_HOME, KEY_SPACE, KEY_END,
|
|
KEY_PAGE_UP, KEY_PAGE_DOWN, KEY_ENTER, KEY_CANCEL)
|
|
from version import has_qwerty
|
|
|
|
# Number of full text lines per screen.
|
|
# - we will draw one past this because on Mk1-4 it shows a partial line under those 4
|
|
if not has_qwerty:
|
|
PER_M = 4
|
|
else:
|
|
from lcd_display import CHARS_H
|
|
PER_M = CHARS_H - 1
|
|
|
|
def numpad_remap(key):
|
|
# map from numpad+2 (12 keys) into symbolic names
|
|
# - might only make sense within context of menus.
|
|
if key == '5':
|
|
return KEY_UP
|
|
elif key == '8':
|
|
return KEY_DOWN
|
|
elif key == '7':
|
|
return KEY_PAGE_UP
|
|
elif key == '9':
|
|
return KEY_END
|
|
elif key == '0':
|
|
return KEY_HOME
|
|
elif key == 'y':
|
|
return KEY_ENTER
|
|
elif key == 'x':
|
|
return KEY_CANCEL
|
|
else:
|
|
# keys 1-4 useful for selecting the top visible items from menu
|
|
return key
|
|
|
|
def start_chooser(chooser):
|
|
# get which one to show as selected, list of choices, and fcn to call after
|
|
# - optional: a function to preview a value
|
|
selected, choices, setter, *preview = chooser()
|
|
|
|
if preview:
|
|
preview, = preview
|
|
|
|
async def picked(menu, picked, xx_self):
|
|
menu.chosen = picked
|
|
menu.show()
|
|
await sleep_ms(100) # visual feedback that we changed it
|
|
setter(picked, choices[picked])
|
|
|
|
the_ux.pop()
|
|
|
|
# make a new menu, just for the choices
|
|
m = MenuSystem([MenuItem(c, f=picked) for c in choices], chosen=selected)
|
|
if preview:
|
|
m.late_draw = lambda dis: preview(m.cursor)
|
|
|
|
the_ux.push(m)
|
|
|
|
class MenuItem:
|
|
def __init__(self, label, menu=None, f=None, chooser=None, arg=None,
|
|
predicate=None, shortcut=None):
|
|
self.label = label
|
|
self.arg = arg
|
|
if menu:
|
|
self.next_menu = menu
|
|
if f:
|
|
self.next_function = f
|
|
if chooser:
|
|
self.chooser = chooser
|
|
if predicate is not None:
|
|
self._predicate = predicate
|
|
if shortcut:
|
|
self.shortcut_key = shortcut
|
|
|
|
def predicate(self):
|
|
if not hasattr(self, "_predicate"):
|
|
return True # does not have predicate - allow
|
|
if callable(self._predicate):
|
|
return self._predicate()
|
|
return self._predicate
|
|
|
|
async def activate(self, menu, idx):
|
|
|
|
if getattr(self, 'chooser', None):
|
|
start_chooser(self.chooser)
|
|
|
|
else:
|
|
# nesting menus, and functions and so on.
|
|
f = getattr(self, 'next_function', None)
|
|
if f:
|
|
rv = await f(menu, idx, self)
|
|
if isinstance(rv, MenuSystem):
|
|
# XXX the function should do this itself, as the_ux.push(rv)
|
|
# replace current with new menu from function
|
|
the_ux.replace(rv)
|
|
|
|
m = getattr(self, 'next_menu', None)
|
|
|
|
if callable(m):
|
|
m = await m(menu, idx, self)
|
|
|
|
if isinstance(m, list):
|
|
m = MenuSystem(m)
|
|
|
|
if m:
|
|
the_ux.push(m)
|
|
|
|
class ShortcutItem(MenuItem):
|
|
# Add these to a menu to define action when a single special key is pressed.
|
|
# - typically NFC and QR keys
|
|
# - never displayed
|
|
# - can have predicate
|
|
def __init__(self, key, **kws):
|
|
super().__init__('SHORTCUT', shortcut=key, **kws)
|
|
|
|
class NonDefaultMenuItem(MenuItem):
|
|
# Show a checkmark if setting is defined and not the default
|
|
def __init__(self, label, nvkey, prelogin=False, default_value=None, **kws):
|
|
super().__init__(label, **kws)
|
|
self.nvkey = nvkey
|
|
self.prelogin = prelogin
|
|
self.def_value = default_value # treated the same as missing
|
|
|
|
def is_chosen(self):
|
|
# should we show a check in parent menu?
|
|
if self.prelogin:
|
|
from nvstore import SettingsObject
|
|
s = SettingsObject.prelogin()
|
|
else:
|
|
from glob import settings
|
|
s = settings
|
|
|
|
return (s.get(self.nvkey, self.def_value) != self.def_value)
|
|
|
|
|
|
class ToggleMenuItem(MenuItem):
|
|
# Handle toggles: must use undefined (missing) as default
|
|
# - can remap values a little, but default is to store 0/1/2
|
|
def __init__(self, label, nvkey, choices, predicate=None, story=None,
|
|
on_change=None, invert=False, value_map=None):
|
|
super().__init__(label, predicate=predicate)
|
|
self.story = story
|
|
self.nvkey = nvkey
|
|
self.choices = choices # list of strings, at least 2
|
|
self.on_change = on_change # optional, since some are just settings
|
|
if invert:
|
|
self.invert = True
|
|
if value_map:
|
|
self.value_map = value_map
|
|
|
|
def get(self, default=None):
|
|
from glob import settings
|
|
return settings.get(self.nvkey, default)
|
|
|
|
def set(self, v):
|
|
from glob import settings
|
|
return settings.set(self.nvkey, v)
|
|
|
|
def remove_key(self):
|
|
from glob import settings
|
|
return settings.remove_key(self.nvkey)
|
|
|
|
def is_chosen(self):
|
|
# should we show a check in parent menu?
|
|
if self.nvkey == "chain":
|
|
rv = True if self.get() in ["XRT", "XTN"] else False
|
|
else:
|
|
rv = bool(self.get(0))
|
|
if getattr(self, 'invert', False):
|
|
rv = not rv
|
|
return rv
|
|
|
|
async def activate(self, menu, idx):
|
|
from ux import ux_show_story
|
|
|
|
# skip story if default value has been changed
|
|
if self.nvkey == "chain":
|
|
default = (self.get() == "BTC")
|
|
else:
|
|
default = (self.get(None) is None)
|
|
if self.story and default:
|
|
ch = await ux_show_story(self.story)
|
|
if ch == 'x': return
|
|
|
|
value = self.get(0)
|
|
if hasattr(self, 'value_map'):
|
|
for n,v in enumerate(self.value_map):
|
|
if value == v:
|
|
value = n
|
|
break
|
|
else:
|
|
value = 0 # robustness
|
|
|
|
m = MenuSystem([MenuItem(c, f=self.picked) for c in self.choices], chosen=value)
|
|
the_ux.push(m)
|
|
|
|
async def picked(self, menu, picked, xx_self):
|
|
menu.chosen = picked
|
|
menu.show()
|
|
await sleep_ms(100) # visual feedback that we changed it
|
|
|
|
if picked == 0:
|
|
self.remove_key()
|
|
else:
|
|
if hasattr(self, 'value_map'):
|
|
picked = self.value_map[picked] # want IndexError if wrong here
|
|
self.set(picked)
|
|
|
|
if self.on_change:
|
|
await self.on_change(picked)
|
|
|
|
the_ux.pop()
|
|
|
|
class PreloginToggleMenuItem(ToggleMenuItem):
|
|
# Handle toggle settings related to pre-login stuff
|
|
|
|
def get(self, default=None):
|
|
from nvstore import SettingsObject
|
|
s = SettingsObject.prelogin()
|
|
return s.get(self.nvkey, default)
|
|
|
|
def set(self, v):
|
|
from nvstore import SettingsObject
|
|
s = SettingsObject.prelogin()
|
|
return s.set(self.nvkey, v)
|
|
|
|
def remove_key(self):
|
|
from nvstore import SettingsObject
|
|
s = SettingsObject.prelogin()
|
|
return s.remove_key(self.nvkey)
|
|
|
|
class MenuSystem:
|
|
|
|
def __init__(self, menu_items, chosen=None, should_cont=None,
|
|
space_indicators=False, multichoice=False):
|
|
self.shortcuts = {}
|
|
self.should_continue = should_cont or (lambda: True)
|
|
self.replace_items(menu_items)
|
|
self.space_indicators = space_indicators
|
|
self.chosen = chosen
|
|
if chosen is not None:
|
|
self.goto_idx(chosen)
|
|
self.multi_selected = [] if multichoice else None
|
|
|
|
# subclasses: override us
|
|
#
|
|
def late_draw(self, dis):
|
|
pass
|
|
|
|
def update_contents(self):
|
|
# something changed in system state; maybe re-construct menu contents
|
|
pass
|
|
|
|
def replace_items(self, menu_items, keep_position=False):
|
|
# only safe to keep position if you know number of items isn't changing
|
|
if not keep_position:
|
|
self.cursor = 0
|
|
self.ypos = 0
|
|
|
|
self.items = [
|
|
m
|
|
for m in menu_items
|
|
if not isinstance(m, ShortcutItem) and m.predicate()
|
|
]
|
|
for m in menu_items:
|
|
if isinstance(m, ShortcutItem):
|
|
self.shortcuts[m.shortcut_key] = m
|
|
|
|
self.count = len(self.items)
|
|
|
|
def goto_label(self, label):
|
|
# pick menu item based on label text
|
|
for i, m in enumerate(self.items):
|
|
if m.label.endswith(label):
|
|
self.goto_idx(i)
|
|
return True
|
|
return False
|
|
|
|
def show(self):
|
|
#
|
|
# Redraw the menu.
|
|
#
|
|
from glob import dis
|
|
|
|
dis.clear()
|
|
|
|
cursor_y = None
|
|
for n in range(self.ypos+PER_M+1):
|
|
real_idx = n+self.ypos
|
|
if real_idx >= self.count: break
|
|
|
|
msg = self.items[real_idx].label
|
|
is_sel = (self.cursor == real_idx)
|
|
if is_sel:
|
|
cursor_y = n
|
|
|
|
# show check?
|
|
checked = (self.chosen is not None) and (real_idx == self.chosen)
|
|
|
|
fcn = getattr(self.items[real_idx], 'is_chosen', None)
|
|
if fcn and fcn():
|
|
checked = True
|
|
|
|
if self.multi_selected is not None and (real_idx in self.multi_selected):
|
|
# ignore length constraint above, we need to visually show that
|
|
# smthg is selected - in any case
|
|
# currently only used with XFPs so checkmark always good
|
|
checked = True
|
|
|
|
dis.menu_draw(n, msg, is_sel, checked, self.space_indicators)
|
|
|
|
# subclass hook
|
|
self.late_draw(dis)
|
|
|
|
if self.count > PER_M:
|
|
dis.scroll_bar(self.ypos, self.count, PER_M)
|
|
|
|
dis.menu_show(cursor_y)
|
|
|
|
def should_wrap_menu(self):
|
|
from glob import settings
|
|
# "wa" is boolean value from config:
|
|
# True --> wrap around all menus
|
|
# False --> (default) wrap around is active only for menus with length > WRAP_IF_OVER
|
|
wrap = settings.get("wa", 0)
|
|
if wrap: return True
|
|
|
|
# Do wrap-around (by request from NVK) if longer than the screen itself (on Q),
|
|
# Mk4: same limit
|
|
return self.count > 10
|
|
|
|
def down(self):
|
|
if self.cursor < self.count-1:
|
|
self.cursor += 1
|
|
|
|
if self.cursor - self.ypos >= (PER_M-1):
|
|
self.ypos += 1
|
|
else:
|
|
if self.should_wrap_menu():
|
|
self.goto_idx(0)
|
|
|
|
def up(self):
|
|
if self.cursor > 0:
|
|
self.cursor -= 1
|
|
if self.cursor < self.ypos:
|
|
self.ypos -= 1
|
|
else:
|
|
if self.should_wrap_menu():
|
|
self.goto_idx(self.count - 1)
|
|
|
|
def top(self):
|
|
self.cursor = 0
|
|
self.ypos = 0
|
|
|
|
def goto_idx(self, n):
|
|
# skip to any item, force cusor near middle of screen
|
|
n = self.count-1 if n >= self.count else n
|
|
n = 0 if n < 0 else n
|
|
self.cursor = n
|
|
if n < PER_M-1:
|
|
self.ypos = 0
|
|
else:
|
|
self.ypos = n - 2
|
|
|
|
def page(self, n):
|
|
# relative page dn/up - may wrap around
|
|
if n == 1:
|
|
for i in range(PER_M):
|
|
self.down()
|
|
else:
|
|
for i in range(PER_M):
|
|
self.up()
|
|
|
|
# events
|
|
async def on_cancel(self):
|
|
# override me
|
|
if the_ux.pop():
|
|
# top of stack (main top-level menu)
|
|
self.top()
|
|
|
|
async def activate(self, picked):
|
|
# Activate a specific choice in our menu.
|
|
#
|
|
if picked is None:
|
|
# "go back" or cancel or something
|
|
await self.on_cancel()
|
|
else:
|
|
await picked.activate(self, self.cursor)
|
|
|
|
|
|
async def interact(self):
|
|
# Only public entry point: do stuff.
|
|
#
|
|
while self.should_continue() and the_ux.top_of_stack() == self:
|
|
ch = await self.wait_choice()
|
|
gc.collect()
|
|
if self.multi_selected is not None:
|
|
# multichoice
|
|
await self.on_cancel()
|
|
return ch
|
|
|
|
await self.activate(ch)
|
|
|
|
async def wait_choice(self):
|
|
# Wait until a menu choice is picked; let them move around
|
|
# the menu, keep redrawing it and so on.
|
|
# returns the item picked, or None for cancel=Back
|
|
|
|
key = None
|
|
|
|
# 5,8 have key-repeat, not others
|
|
pr = PressRelease('790xy') # on Q, arg is ignored
|
|
|
|
while 1:
|
|
self.show()
|
|
|
|
key = await pr.wait()
|
|
|
|
if not key:
|
|
continue
|
|
|
|
if not has_qwerty:
|
|
key = numpad_remap(key)
|
|
|
|
if self.multi_selected is not None and (key == "1"): #1 is select/deselect key for both HW
|
|
# multichoice
|
|
if self.cursor in self.multi_selected:
|
|
# already chosen - and user pressed again
|
|
# unselect
|
|
self.multi_selected.remove(self.cursor)
|
|
else:
|
|
# select
|
|
self.multi_selected.append(self.cursor)
|
|
|
|
elif key in KEY_ENTER+KEY_SPACE:
|
|
if self.multi_selected is not None:
|
|
# selected - multichoice done
|
|
return self.multi_selected
|
|
|
|
return self.items[self.cursor]
|
|
|
|
elif key == KEY_CANCEL:
|
|
# abort/nothing selected/back out?
|
|
return None
|
|
elif key == KEY_UP:
|
|
self.up()
|
|
elif key == KEY_DOWN:
|
|
self.down()
|
|
elif key == KEY_PAGE_UP:
|
|
self.page(-1)
|
|
elif key == KEY_PAGE_DOWN:
|
|
self.page(1)
|
|
elif key == KEY_END:
|
|
self.goto_idx(self.count-1)
|
|
elif key == KEY_HOME:
|
|
# zip to top, no selection
|
|
self.cursor = 0
|
|
self.ypos = 0
|
|
elif '1' <= key <= '9':
|
|
# jump down, based on screen postion
|
|
self.goto_idx(ord(key)-ord('1'))
|
|
elif key in self.shortcuts:
|
|
# run the function, if predicate allows
|
|
m = self.shortcuts[key]
|
|
if m.predicate():
|
|
return m
|
|
else:
|
|
# maybe a shortcut?
|
|
for n, item in enumerate(self.items):
|
|
if getattr(item, 'shortcut_key', None) == key:
|
|
# matched. do it
|
|
self.goto_idx(n)
|
|
return self.items[self.cursor]
|
|
|
|
# search downwards for a menu item that starts with indicated letter
|
|
# if found, select it but don't drill down
|
|
lst = list(range(self.cursor+1, self.count)) + list(range(0, self.cursor))
|
|
for n in lst:
|
|
if self.items[n].label[0].upper() == key.upper():
|
|
self.goto_idx(n)
|
|
break
|
|
|
|
# EOF
|