512 lines
14 KiB
Python
512 lines
14 KiB
Python
# (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
|