# (c) Copyright 2018 by Coinkite Inc. This file is covered by license found in COPYING-CC. # # ux_mk4.py - UX/UI interactions that are Mk1-4 specific # from uasyncio import sleep_ms from utils import pretty_short_delay from exceptions import AbortInteraction class PressRelease: def __init__(self, need_release='xy'): # Manage key-repeat: track last key, measure time it's held down, etc. self.need_release = need_release self.last_key = None self.num_repeats = 0 async def wait(self): from glob import numpad armed = None while 1: # two values here: # - (ms) time to wait before first key-repeat # - (ms) time between 2nd and Nth repeated events # - these values approved by @nvk rep_delay = 200 if not self.num_repeats else 20 so_far = 0 while numpad.empty(): if self.last_key and numpad.key_pressed == self.last_key: if so_far >= rep_delay: self.num_repeats += 1 return self.last_key await sleep_ms(1) so_far += 1 ch = numpad.get_nowait() if ch == numpad.ABORT_KEY: raise AbortInteraction() self.num_repeats = 0 if len(ch) > 1: # multipress: cancel press/release cycle and be a keyup # for other keys. armed = None continue if ch == '': self.last_key = None if armed: return armed elif ch in self.need_release: # no key-repeat on these ones armed = ch else: self.last_key = ch return ch async def ux_enter_number(prompt, max_value, can_cancel=True, value=''): # return the decimal number which the user has entered # - default/blank value assumed to be zero # - clamps large values to the max from glob import dis from display import FontTiny from math import log # allow key repeat on X only press = PressRelease('1234567890y') y = 26 value = str(value) max_w = int(log(max_value, 10) + 1) dis.clear() dis.text(0, 0, prompt) dis.text(None, -1, ("X to CANCEL, or OK when DONE." if can_cancel else "X to DELETE, or OK when DONE."), FontTiny) dis.save() while 1: dis.restore() # text centered if value: bx = dis.text(None, y, value) dis.icon(bx+1, y+11, 'space') else: dis.icon(64-7, y+11, 'space') dis.show() ch = await press.wait() if ch == 'y': if not value: return 0 return min(max_value, int(value)) elif ch == 'x': if value: value = value[0:-1] elif can_cancel: # quit if they press X on empty screen return None else: if len(value) == max_w: value = value[0:-1] + ch else: value += ch # cleanup leading zeros and such value = str(min(int(value), max_value)) async def ux_input_digits(val, prompt=None, maxlen=32): # collect a series of digits. from glob import dis from display import FontTiny # allow key repeat on X only press = PressRelease('1234567890y') footer = "X to DELETE, or OK when DONE." lx = 6 y = 16 here = '' dis.clear() dis.text(None, -1, footer, FontTiny) if prompt: dis.text(0, 0, prompt) y += 8 dis.save() while 1: dis.restore() # text centered msg = here by = y bx = dis.text(lx, y, msg[0:16]) dis.text(lx, y - 9, str(val, 'ascii').replace(' ', '_'), FontTiny) if len(msg) > 16: # second line when needed (left just) by += 15 bx = dis.text(lx, by, msg[16:]) if len(here) < 32: dis.icon(bx, by - 2, 'sm_box') dis.show() ch = await press.wait() if ch == 'y': val += here return val elif ch == 'x': if here: here = here[0:-1] else: # quit if they press X on empty screen return else: if len(here) < maxlen: here += ch async def ux_input_text(pw, confirm_exit=True, hex_only=False, max_len=100, min_len=0, **_kws): # Allow them to pick each digit using "D-pad" # - Q1 version of this function can do much more w/ more keyword args from glob import dis from display import FontTiny, FontSmall from ux import ux_show_story # Should allow full unicode, NKDN # - but limited to what we can show in FontSmall # - so really just ascii; not even latin-1 # - 8-bit codepoints only my_rng = range(32, 127) # FontSmall.code_range if hex_only: new_expand = "0" symbols = b"0123456789abcdef" else: new_expand = " " symbols = b' !"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~' letters = b'abcdefghijklmnopqrstuvwxyz' Letters = b'ABCDEFGHIJKLMNOPQRSTUVWXYZ' numbers = b'1234567890' # assert len(set(symbols+letters+Letters+numbers)) == len(my_rng) if hex_only: footer1 = "Enter Hexidecimal Number" footer2 = "58=Change 9=Next 7=Back" else: footer1 = "1=Letters 2=Numbers 3=Symbols" footer2 = "4=SwapCase 0=HELP" y = 20 pw = bytearray(pw or ('0' if hex_only else 'A')) pos = len(pw) - 1 # which part being changed n_visible = const(9) scroll_x = max(pos - n_visible, 0) def cycle_set(which, direction=1): # pick next item in set of choices for n, s in enumerate(which): if pw[pos] == s: try: pw[pos] = which[n + direction] except IndexError: pw[pos] = which[0 if direction == 1 else -1] return pw[pos] = which[0] def change(dx): # next/prev within the same subset of related chars ch = pw[pos] if hex_only: return cycle_set(symbols, dx) for subset in [symbols, letters, Letters, numbers]: if ch in subset: return cycle_set(subset, dx) # probably unreachable code: numeric up/down ch = pw[pos] + dx if ch not in my_rng: ch = (my_rng.stop - 1) if dx < 0 else my_rng.start assert ch in my_rng pw[pos] = ch # pre-render the fixed stuff dis.clear() dis.text(None, -10, footer1, FontTiny) dis.text(None, -1, footer2, FontTiny) dis.save() # no key-repeat on certain keys press = PressRelease('4xy') while 1: dis.restore() lr = pos - scroll_x # left/right distance of cursor if lr < 4 and scroll_x: scroll_x -= 1 elif lr < 0: scroll_x = pos elif lr >= (n_visible - 1): # past right edge scroll_x += 1 for i in range(n_visible): # calc abs position in string ax = scroll_x + i x = 4 + (13 * i) try: ch = pw[ax] except IndexError: continue if ax == pos: # draw cursor if not hex_only and (len(pw) < 2 * n_visible): dis.text(x - 4, y - 19, '0x%02X' % ch, FontTiny) dis.icon(x - 2, y - 10, 'spin') if ch == 0x20: dis.icon(x, y + 11, 'space') else: dis.text(x, y, chr(ch) if ch in my_rng else chr(215), FontSmall) if scroll_x > 0: dis.text(2, y - 14, str(pw, 'ascii')[0:scroll_x].replace(' ', '_'), FontTiny) if scroll_x + n_visible < len(pw): dis.text(-1, 1, "MORE>", FontTiny) dis.show() ch = await press.wait() if ch == 'y': if len(pw) < min_len: ch = await ux_show_story('Need %d characters at least. Press OK ' 'to continue X to exit.' % min_len, escape="xy", strict_escape=True) if ch == "x": return continue return str(pw, 'ascii') elif ch == 'x': if len(pw) > 1: # delete current char pw = pw[0:pos] + pw[pos + 1:] if pos >= len(pw): pos = len(pw) - 1 else: if confirm_exit: pp = await ux_show_story( "OK to leave without any changes? Or X to cancel leaving.") if pp == 'x': continue return None elif ch == '7': # left pos -= 1 if pos < 0: pos = 0 elif ch == '9': # right pos += 1 if pos >= len(pw): if len(pw) < max_len and pw[-3:] != b' ': # expands with space in normal mode # expands with 0 in hex_only mode pw += new_expand else: pos -= 1 # abort addition elif ch == '5': # up change(1) elif ch == '8': # down change(-1) elif hex_only: # just got back at the beginning of the loop # below branches are unreachable for hex_only mode pass elif ch == '1': # alpha cycle_set(b'Aa') elif ch == '4': # toggle case if (pw[pos] & ~0x20) in range(65, 91): pw[pos] ^= 0x20 elif ch == '2': # numbers cycle_set(numbers) elif ch == '3': # symbols (all of them) cycle_set(symbols) elif ch == '0': # help help_msg = '''\ Use arrow keys (5789) to select letter and move around. 1=Letters (Aa..) 2=Numbers (12..) 3=Symbols (!@#&*) 4=Swap Case (q/Q) X=Delete char Add more characters by moving past end (right side).''' if confirm_exit: help_msg += '\nTo quit without changes, delete everything.' await ux_show_story(help_msg) def ux_show_phish_words(dis, words): # Show the 2 words during login/pin change process from display import FontLarge, FontTiny y = 15 x = 18 dis.text(x, y, words[0], FontLarge) dis.text(x, y+18, words[1], FontLarge) dis.text(None, -1, "X to CANCEL, or OK to CONTINUE", FontTiny) def ux_show_pin(dis, pin, subtitle, prefix, is_confirmation, force_draw, footer=None, randomize=None): # Draw PIN during login process, as they enter it. from display import FontTiny, FontLarge is_first_part = not bool(prefix) if randomize: # screen redraw, when we are "randomized" # - only used at login, none of the other cases if force_draw: dis.clear() # prompt dis.text(5+3, 2, "ENTER PIN") dis.text(5+6, 17, ('1st part' if is_first_part else '2nd part')) # remapped keypad y = 2 x = 89 h = 16 for i in range(0, 10, 3): if i == 9: dis.text(x, y, ' %s' % randomize[0]) else: dis.text(x, y, ' '.join(randomize[1+i:1+i+3])) y += h else: # just clear what we need to: the PIN area dis.clear_rect(0, 40, 88, 20) # placeholder text msg = '[' + ('*'*len(pin)) + ']' x = 40 - ((10*len(msg))//2) dis.text(x, 40, msg, FontLarge) dis.show() return filled = len(pin) y = 27 if force_draw: dis.clear() if is_first_part: prompt="Enter PIN Prefix" else: prompt="Enter rest of PIN" if subtitle: dis.text(None, 0, subtitle) dis.text(None, 16, prompt, FontTiny) else: dis.text(None, 4, prompt) if footer: pass elif is_confirmation: footer = "CONFIRM PIN VALUE" elif is_confirmation: footer = "X to CANCEL, or OK when DONE" else: footer = "X to CANCEL, or OK to CONTINUE" dis.text(None, -1, footer, FontTiny) else: # just clear what we need to: the PIN area dis.clear_rect(0, y, 128, 21) w = 18 # extra (empty) box after if not filled: dis.icon(64-(w//2), y, 'box') else: x = 64 - ((w*filled)//2) # filled boxes for idx in range(filled): dis.icon(x, y, 'xbox') x += w dis.show() async def ux_login_countdown(sec): # Show a countdown, which may need to # run for multiple **days** from glob import dis from display import FontSmall, FontLarge from utime import ticks_ms, ticks_diff # pre-render fixed parts dis.clear() y = 0 dis.text(None, y, 'Login countdown in', font=FontSmall); y += 14 dis.text(None, y, 'effect. Must wait:', font=FontSmall); y += 14 y += 5 dis.save() st = ticks_ms() while sec > 0: dis.restore() dis.text(None, y, pretty_short_delay(sec), font=FontLarge) dis.show() dis.busy_bar(1) # this should be more accurate, errors were accumulating now = ticks_ms() dt = 1000 - ticks_diff(now, st) await sleep_ms(dt) st = ticks_ms() sec -= 1 dis.busy_bar(0) def ux_dice_rolling(): from glob import dis from display import FontTiny, FontLarge # draw fixed parts of screen dis.clear() y = 38 dis.text(0, y, "Press 1-6 for each dice"); y += 13 dis.text(0, y, "roll to mix in.") dis.save() def update(count, hx=None): dis.restore() dis.text(None, 0, '%d rolls' % count, FontLarge) if hx is not None: dis.text(0, 20, hx[0:32], FontTiny) dis.text(0, 20+7, hx[32:], FontTiny) dis.show() # return funct to draw updating part return update def ux_render_words(words, **kws): # caution: text layout here, and flag sensitive=T trigger side-channel defenses return '\n'.join('%2d: %s' % (i+1, w) for i,w in enumerate(words)) # EOF