fancy text entry

This commit is contained in:
Peter D. Gray 2023-08-04 11:04:55 -04:00 committed by scgbckbone
parent f3527e1960
commit f69157dcd9
7 changed files with 184 additions and 36 deletions

View File

@ -21,6 +21,7 @@ from glob import settings
from pincodes import pa
from menu import start_chooser
from version import MAX_TXN_LEN
from charcodes import KEY_NFC
CLEAR_PIN = '999999-999999'
@ -1021,7 +1022,7 @@ async def export_xpub(label, _2, item):
if '{acct}' in path:
msg += "Press (1) to select account other than zero. "
if glob.NFC:
msg += "Press (3) to share via NFC. "
msg += "Press (" + ("nfc" if qwerty else "3") + ") to share via NFC. "
ch = await ux_show_story(msg, escape='13')
if ch == 'x': return
@ -1041,7 +1042,7 @@ async def export_xpub(label, _2, item):
node = sv.derive_path(path) if path != 'm' else sv.node
xpub = chain.serialize_public(node, addr_fmt)
if ch == '3' and glob.NFC:
if glob.NFC and ch in '3'+KEY_NFC:
await glob.NFC.share_text(xpub)
else:
from ux import show_qr_code
@ -1316,7 +1317,7 @@ async def import_xprv(_1, _2, item):
prompt, escape = import_prompt_builder("%s file" % label)
if prompt:
ch = await ux_show_story(prompt, escape=escape)
if ch == "3":
if ch in "3"+KEY_NFC:
force_vdisk = None
extended_key = await NFC.read_extended_private_key()
if not extended_key:
@ -1481,7 +1482,7 @@ async def import_tapsigner_backup_file(_1, _2, item):
prompt, escape = import_prompt_builder(label)
if prompt:
ch = await ux_show_story(prompt, escape=escape)
if ch == "3":
if ch in "3"+KEY_NFC:
force_vdisk = None
data = await NFC.read_tapsigner_b64_backup()
if not data:
@ -1505,17 +1506,18 @@ async def import_tapsigner_backup_file(_1, _2, item):
data = fp.read()
if await ux_show_story("Make sure to have your TAPSIGNER handy as you will need to provide "
"'Backup Password' from the back of the card in the next step. "
"'Backup Password' from the back of the card in the next step.\n\n"
"Press OK to continue X to cancel.") != "y":
return
while True:
backup_key = await ux_input_text("", confirm_exit=False, hex_only=True, max_len=32)
backup_key = await ux_input_text("", confirm_exit=False, hex_only=True,
min_len=32, max_len=32, prompt='Backup Password (32 hex digits)')
if backup_key is None:
return
if len(backup_key) != 32:
await ux_show_story(title="FAILURE", msg="'Backup Key' length != 32")
continue
assert len(backup_key) == 32
try:
extended_key, derivation = decrypt_tapsigner_backup(backup_key, data)
break

View File

@ -82,13 +82,14 @@ DECODER_CAPS = (KEY_NFC + KEY_QR + KEY_TAB
# when SYMBOL pressed
# - be nice and allow number+symbol == number + shift
# - also nicity: symb+cancel=clear
DECODER_SYMBOL = (KEY_NFC + KEY_QR + KEY_TAB
+ KEY_HOME + KEY_PAGE_UP + KEY_PAGE_DOWN + KEY_END + KEY_CANCEL + KEY_SELECT + '\0'
+ '!@#$%^&*()'
+ '-_`\0\0\0[]{}'
+ '+=\0\0:;~|\\"'
+ KEY_F1 + KEY_F2 + KEY_F3 + KEY_F4 + KEY_F5 + KEY_F6 + '\0<>?'
'\0\0\0\0\0\0\0\0\0\0' )
'\0\0\0\0' + KEY_CLEAR + '\0\0\0\0\0' )
KEYNUM_LAMP = const(50)
KEYNUM_SHIFT = const(51)

View File

@ -341,6 +341,8 @@ class Display:
if cursor:
# implement CursorSpec values
assert 0 <= cursor.x < CHARS_W, 'cur x'
assert 0 <= cursor.y < CHARS_H, 'cur y'
self.gpu.cursor_at(*cursor)
self.last_buf[cursor.y][cursor.x] = 0xfffd
if cursor.dbl_wide:
@ -589,6 +591,33 @@ class Display:
self.show()
def draw_box(self, x, y, w, h):
# using line-drawing chars, draw a box
# returns X pos of first inside char
assert 0 <= h <= CHARS_H-2 # 8 max
assert 0 <= w <= CHARS_W-2 # 32 max
if x is None:
x = (CHARS_W - w - 2) // 2
ln = '' + (''*w) + ''
self.text(x, y, ln)
for yy in range(y+1, y+h+1):
self.text(x, yy, '')
self.text(x+w+1, yy, '')
ln = '' + ln[1:-1] + ''
self.text(x, y+h+1, ln)
return x+1
def clear_box(self, x, y, w, h):
# clear (w/ spaces) a box on screen
for Y in range(y, y+h):
for X in range(x, x+w):
assert 0 <= X < CHARS_W
assert 0 <= Y < CHARS_H
self.next_buf[Y][X] = 32
# here for mpy reasons
WIDTH = const(320)

View File

@ -342,7 +342,7 @@ class MenuSystem:
if not has_qwerty:
key = numpad_remap(key)
if key == KEY_SELECT:
if key == KEY_SELECT or key == ' ':
# selected - done
return self.cursor
elif key == KEY_CANCEL:

View File

@ -1167,7 +1167,8 @@ class PassphraseMenu(MenuSystem):
async def view_edit_phrase(self, *a):
# let them control each character
global pp_sofar
pw = await ux_input_text(pp_sofar)
pw = await ux_input_text(pp_sofar, prompt="Your BIP-39 Passphrase",
b39_complete=True, max_len=100)
if pw is not None:
pp_sofar = pw
self.check_length()

View File

@ -166,8 +166,9 @@ async def ux_input_numbers(val, validate_func):
if len(here) < 32:
here += ch
async def ux_input_text(pw, confirm_exit=True, hex_only=False, max_len=100):
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
@ -277,6 +278,9 @@ async def ux_input_text(pw, confirm_exit=True, hex_only=False, max_len=100):
ch = await press.wait()
if ch == 'y':
if len(pw) < min_len:
# enforce a minimum length, better: say so.
continue
return str(pw, 'ascii')
elif ch == 'x':
if len(pw) > 1:

View File

@ -7,6 +7,7 @@ import utime, gc
from charcodes import *
from lcd_display import CHARS_W, CursorSpec
from exceptions import AbortInteraction
import bip39
class PressRelease:
def __init__(self, need_release=KEY_SELECT+KEY_CANCEL):
@ -115,19 +116,22 @@ async def ux_input_numbers(val, validate_func):
# - not wanted on Q1; just get the digits w/ the text.
pass
async def ux_input_text(value, confirm_exit=True, hex_only=False, max_len=100):
async def ux_input_text(value, confirm_exit=True, hex_only=False, max_len=100,
prompt='Enter value', min_len=0, b39_complete=False, scan_ok=False, num_words=0):
# Get a text string.
# - Should allow full unicode, NKDN
# - but our font is mostly just ascii
# - no control chars allowed either
# - TODO: editing, line wrap, seed completion, etc
# - TODO: press QR -> do scan and use that text
# - TODO: regex validation for derviation paths
# - TODO: assume phrase seed entry
from glob import dis
from ux import ux_show_story
dis.clear()
dis.text(None, -2, "Use ↦ to auto-complete words.")
if b39_complete or num_words:
scan_ok = True
dis.text(None, -2, "↦ to auto-complete. (QR) to scan.")
dis.text(None, -1, "CANCEL or SELECT when done.")
# TODO:
@ -135,32 +139,135 @@ async def ux_input_text(value, confirm_exit=True, hex_only=False, max_len=100):
# - multi line support
# - add prompt text?
# map from what they entered, to allowed char. None if not allowed char
# - can case fold if desired
ch_remap = lambda ch: ch if ' ' <= ch < chr(127) else None
if hex_only:
ch_remap = lambda ch: ch.lower() if ch in '0123456789abcdefABCDEF' else None
y = 2
if max_len <= CHARS_W-2:
# single-line or perhaps shorter value
line_len = max_len
num_lines = 1
y = 4
elif max_len == 100:
# passphrase case, handle nicely
line_len = 25
num_lines = 4
else:
# multi-line mode: just do a box for most of screen
num_lines = 6
line_len = CHARS_W-2
dis.text(None, y-2, prompt)
x = dis.draw_box(None, y-1, line_len, num_lines)
# NOTE:
# - x,y here are top left of entry area
# - not allow cursor movement, always appending to end
# no key-repeat on certain keys
err_msg = last_err = None
press = PressRelease()
while 1:
dis.clear_box(x, y, line_len, num_lines)
dis.text(1, 1, value+' ')
bx = dis.width(value) + 1
dis.show(cursor=CursorSpec(bx, 1, 0, 0))
# show error msg, until they type anything to clear it
if err_msg:
dis.text(None, y+num_lines+1, err_msg)
err_msg = None
last_err = True
elif last_err:
dis.text(None, y+num_lines+1, '')
last_err = False
if not value:
bx = 0
n = 0
else:
for n, ln_pos in enumerate(range(0, len(value), line_len)):
ln = value[ln_pos:ln_pos+line_len]
dis.text(x, y+n, ln)
bx = len(ln)
# decide cursor appearance
cur = CursorSpec(x+bx, y+n, 0, 0)
if cur.x >= x+line_len:
# outline mode if on final possible location
cur = CursorSpec(x+line_len-1, y+n, 0, True)
dis.show(cursor=cur)
ch = await press.wait()
if ch == KEY_SELECT:
return str(value, 'ascii')
elif ch == KEY_DELETE:
if len(value) >= min_len:
return value
else:
err_msg = 'Need %d characters at least.' % min_len
elif ch == KEY_DELETE or ch == KEY_LEFT:
if len(value) > 0:
# delete current char
# delete last char
value = value[:-1]
elif ch == KEY_CLEAR:
value = ''
dis.text(0, 1, ' '*CHARS_W)
elif ch == KEY_CANCEL:
if confirm_exit:
pp = await ux_show_story(
"OK to leave without any changes? Or CANCEL to avoid leaving.")
if pp == KEY_CANCEL: continue
if pp == KEY_CANCEL:
continue
return None
elif ' ' <= ch < chr(127):
value += ch
elif b39_complete and ch == KEY_TAB:
# match case and auto-complete BIP-39 word if we can
# - search backwards for alpha chars, up to 5
# - stop on first non-letter
# - break if case changes, so "ZooAct" gives "Act"
pref = []
for b in reversed(value[-4:]):
if not b: break
if 'a' <= b.lower() <= 'z':
pref.insert(0, b)
if len(pref)>=1 and b.isupper() != pref[0].isupper():
break
else:
break
if not pref:
#err_msg = 'Need some letters first.'
continue
pref = ''.join(pref)
exact, nextchars, is_word = bip39.next_char(pref.lower())
if is_word:
# got a match; append it
if pref.isupper():
# all upper case, so append w/ same
# - Titlecase will just happen w/o any code here
is_word = is_word.upper()
value += is_word[len(pref):]
elif not nextchars:
err_msg = 'Not a prefix BIP-39 word: ' + pref
elif len(nextchars) < 12:
# 'sta' and other s-prefixes
err_msg = 'Press next key: ' + nextchars
else:
err_msg = 'Need more letters.'
print(pref)
else:
ch = ch_remap(ch)
if ch is not None:
if len(value) < max_len:
value += ch
else:
value = value[0:max_len-1] + ch
def ux_show_pin(dis, pin, subtitle, is_first_part, is_confirmation, force_draw,
footer=None, randomize=None):
@ -178,16 +285,16 @@ def ux_show_pin(dis, pin, subtitle, is_first_part, is_confirmation, force_draw,
# - only used at login, none of the other cases
# - test w/ "simulator.py --q1 -g --eff --set rngk=1"
# remapped numbers along bottom
dis.text(1, -5, ' 1 2 3 4 5 6 7 8 9 0 ', invert=1)
dis.text(1, -4, '' + ' '.join(randomize[1:]) + ' ' + randomize[0])
# show mapping of numbers vs. PIN digits
dis.text(1, -5, ' ' + ' '.join(randomize[1:]) + ' ' + randomize[0] + ' ', invert=1)
dis.text(1, -4, '1 2 3 4 5 6 7 8 9 0')
if force_draw:
if is_first_part:
prompt="Enter FIRST part of PIN (XXX-)"
prompt="Enter FIRST part of PIN (xxx-)"
else:
prompt="Enter SECOND part of PIN (-YYY)"
prompt="Enter SECOND part of PIN (-yyy)"
if subtitle:
# "New Main PIN" ... so not really a SUB title.
@ -219,18 +326,22 @@ def ux_show_pin(dis, pin, subtitle, is_first_part, is_confirmation, force_draw,
async def ux_login_countdown(sec):
# Show a countdown, which may need to
# run for multiple **days**
# XXX untested TODO
# - test with: ./simulator.py --q1 -g --eff --set lgto=60
# - test with: ./simulator.py --q1 -g --eff --set lgto=3600
# - test with: ./simulator.py --q1 -g --eff --set lgto=2419200
from glob import dis
from utime import ticks_ms, ticks_diff
from utils import pretty_short_delay, pretty_delay
y = 4
y = 1
dis.clear()
dis.text(None, y-2, "Login countdown in effect.", invert=1)
dis.text(None, y-1, "Must wait:")
dis.text(None, y, "Login countdown in effect.", invert=1)
dis.text(None, y+2, "Must wait:")
st = ticks_ms()
while sec > 0:
dis.text(None, y, pretty_short_delay(sec))
txt = pretty_delay(sec) if sec > 12*3600 else pretty_short_delay(sec)
dis.text(None, y+4, txt)
dis.busy_bar(1)
# this should be more accurate, errors were accumulating