secure notes and passwords
This commit is contained in:
parent
7ac6915202
commit
17d0e7d345
@ -216,6 +216,10 @@ def restore_from_dict_ll(vals):
|
||||
# saving into settings
|
||||
continue
|
||||
|
||||
if k == 'notes' and not version.has_qwerty:
|
||||
# Secure notes only supported on keyboard-equiped units
|
||||
continue
|
||||
|
||||
settings.set(k, vals[key])
|
||||
|
||||
# write out
|
||||
|
||||
@ -38,16 +38,18 @@ KEY_SYMBOL = '\x02'
|
||||
KEY_DELETE = '\x08' # ^H = backspace
|
||||
KEY_CLEAR = '\x15' # ^U = clear entry
|
||||
|
||||
# function keys, filling gaps, running out of space!
|
||||
KEY_F1 = '\x0f'
|
||||
KEY_F2 = '\x12'
|
||||
KEY_F3 = '\x13'
|
||||
KEY_F4 = '\x14'
|
||||
KEY_F5 = '\x16'
|
||||
KEY_F6 = '\x17'
|
||||
|
||||
# save memory on Mk4
|
||||
if has_qwerty:
|
||||
|
||||
# function keys, filling gaps, running out of space!
|
||||
KEY_F1 = '\x0f'
|
||||
KEY_F2 = '\x12'
|
||||
KEY_F3 = '\x13'
|
||||
KEY_F4 = '\x14'
|
||||
KEY_F5 = '\x16'
|
||||
KEY_F6 = '\x17'
|
||||
KEYS_FUNCTION = KEY_F1 + KEY_F2 + KEY_F3 + KEY_F4 + KEY_F5 + KEY_F6
|
||||
|
||||
NUM_ROWS = const(6)
|
||||
NUM_COLS = const(10)
|
||||
|
||||
|
||||
@ -47,6 +47,7 @@ still backed-up.''')
|
||||
if not await ux_confirm(msg):
|
||||
return
|
||||
|
||||
# XXX changes in this order will break lots of stuff
|
||||
choices = [ '12 words', '18 words', '24 words', 'WIF (privkey)',
|
||||
'XPRV (BIP-32)', '32-bytes hex', '64-bytes hex', 'Passwords']
|
||||
|
||||
@ -112,7 +113,11 @@ def bip85_pwd(secret):
|
||||
secret_b64 = b2a_base64(secret).decode().strip()
|
||||
return secret_b64[:BIP85_PWD_LEN]
|
||||
|
||||
async def drv_entro_step2(_1, picked, _2):
|
||||
async def pick_bip85_password():
|
||||
# ask for index and then return the pw (see notes.py)
|
||||
return await drv_entro_step2(None, 7, None, just_pick=True)
|
||||
|
||||
async def drv_entro_step2(_1, picked, _2, just_pick=False):
|
||||
from glob import dis
|
||||
from files import CardSlot, CardMissingError, needs_microsd
|
||||
|
||||
@ -127,6 +132,9 @@ async def drv_entro_step2(_1, picked, _2):
|
||||
dis.fullscreen("Working...")
|
||||
new_secret, width, s_mode, path = bip85_derive(picked, index)
|
||||
|
||||
if just_pick:
|
||||
return bip85_pwd(new_secret)
|
||||
|
||||
# Reveal to user!
|
||||
encoded = None
|
||||
chain = chains.current_chain()
|
||||
|
||||
@ -34,14 +34,16 @@ else:
|
||||
hsm_feature = lambda: False
|
||||
make_users_menu = lambda: []
|
||||
|
||||
# Battery related items
|
||||
# Q related items
|
||||
if version.has_battery:
|
||||
from battery import battery_idle_timeout_chooser, brightness_chooser
|
||||
from q1 import scan_and_bag
|
||||
from notes import make_notes_menu
|
||||
else:
|
||||
battery_idle_timeout_chooser = None
|
||||
brightness_chooser = None
|
||||
scan_and_bag = None
|
||||
make_notes_menu = None
|
||||
|
||||
|
||||
#
|
||||
@ -298,6 +300,8 @@ AdvancedNormalMenu = [
|
||||
MenuItem('Export Wallet', predicate=has_secrets, menu=WalletExportMenu), # also inside FileMgmt
|
||||
MenuItem("Upgrade Firmware", menu=UpgradeMenu, predicate=is_not_tmp),
|
||||
MenuItem("File Management", menu=FileMgmtMenu),
|
||||
NonDefaultMenuItem('Secure Notes & Passwords', 'notes', menu=make_notes_menu,
|
||||
predicate=lambda: version.has_qwerty),
|
||||
MenuItem('Derive Seed B85', f=drv_entro_start),
|
||||
MenuItem("View Identity", f=view_ident),
|
||||
MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu),
|
||||
@ -361,6 +365,8 @@ NormalSystem = [
|
||||
menu=NFCToolsMenu, shortcut=KEY_NFC),
|
||||
MenuItem('Start HSM Mode', f=start_hsm_menu_item, predicate=hsm_policy_available),
|
||||
MenuItem("Address Explorer", f=address_explore, shortcut='x'),
|
||||
MenuItem('Secure Notes & Passwords', menu=make_notes_menu, shortcut='n',
|
||||
predicate=lambda: (settings.get("notes", False) != False)),
|
||||
MenuItem('Type Passwords', f=password_entry, shortcut='t',
|
||||
predicate=lambda: settings.get("emu", False) and has_secrets()),
|
||||
MenuItem('Seed Vault', menu=make_seed_vault_menu, shortcut='v',
|
||||
|
||||
@ -16,6 +16,7 @@ freeze_as_mpy('', [
|
||||
'trick_pins.py',
|
||||
'ux_q1.py',
|
||||
'battery.py',
|
||||
'notes.py',
|
||||
], opt=0)
|
||||
|
||||
# Optimize data-like files, since no need to debug them.
|
||||
|
||||
@ -122,7 +122,7 @@ class NonDefaultMenuItem(MenuItem):
|
||||
from glob import settings
|
||||
s = settings
|
||||
|
||||
return bool(s.get(self.nvkey, self.def_value))
|
||||
return (s.get(self.nvkey, self.def_value) != self.def_value)
|
||||
|
||||
|
||||
class ToggleMenuItem(MenuItem):
|
||||
@ -412,7 +412,5 @@ class MenuSystem:
|
||||
if self.items[n].label[0].upper() == key.upper():
|
||||
self.goto_idx(n)
|
||||
break
|
||||
else:
|
||||
print("Unused menu key: %s=0x%02x" % (key, ord(key)))
|
||||
|
||||
# EOF
|
||||
|
||||
380
shared/notes.py
Normal file
380
shared/notes.py
Normal file
@ -0,0 +1,380 @@
|
||||
# (c) Copyright 2024 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
#
|
||||
# notes.py - Store some short notes, securely.
|
||||
#
|
||||
import ngu, bip39
|
||||
from menu import MenuItem, MenuSystem
|
||||
from ux import ux_show_story, the_ux, ux_dramatic_pause, ux_confirm, the_ux
|
||||
from ux import PressRelease, ux_input_numbers, ux_input_text, show_qr_code
|
||||
from actions import goto_top_menu
|
||||
from glob import settings, dis
|
||||
from files import CardMissingError, needs_microsd, CardSlot
|
||||
from charcodes import KEY_QR, KEY_ENTER, KEY_CANCEL, KEY_CLEAR
|
||||
from charcodes import KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5, KEY_F6
|
||||
from lcd_display import CHARS_W
|
||||
|
||||
ONE_LINE = CHARS_W-2
|
||||
|
||||
async def make_notes_menu(*a):
|
||||
if settings.get('notes', False) == False:
|
||||
# Explain feature, and then enable if interested. Drop them into menu.
|
||||
ch = await ux_show_story('''\
|
||||
Enable this feature to store short text notes and passwords inside the Coldcard.
|
||||
|
||||
The notes are encrypted along with your other settings and will be backed-up with them.
|
||||
|
||||
Press ENTER to enable and get started otherwise CANCEL.''',
|
||||
title="Secure Notes")
|
||||
|
||||
if ch != 'y':
|
||||
return
|
||||
|
||||
# mark as enabled (altho empty)
|
||||
settings.set('notes', [])
|
||||
|
||||
# need to correct top menu now, so this choice is there.
|
||||
|
||||
return NotesMenu(NotesMenu.construct())
|
||||
|
||||
async def get_a_password(old_value):
|
||||
# Get a (new) password as a string.
|
||||
# - does some fun generation as well.
|
||||
|
||||
from seed import generate_seed
|
||||
from drv_entro import bip85_pwd, pick_bip85_password
|
||||
from random import randbelow, shuffle
|
||||
|
||||
async def _pick_12(was):
|
||||
# 128 bits
|
||||
seed = generate_seed()[0:16]
|
||||
return bip39.b2a_words(seed)
|
||||
|
||||
async def _pick_24(was):
|
||||
# 256 bits
|
||||
seed = generate_seed()
|
||||
return ' '.join(w[0:4] for w in bip39.b2a_words(seed).split())
|
||||
|
||||
async def _pick_dense(was):
|
||||
# 126 bits, 21 chars ... base64 but no symbols
|
||||
seed = generate_seed() + generate_seed()
|
||||
return bip85_pwd(seed).replace('+', 'P').replace('/', 's')
|
||||
|
||||
async def _do_dumb(was):
|
||||
# mixed case, number and symbol for bullshit site rules
|
||||
# entropy: 11+11 + (3.8*3) + 16 = 49 bits
|
||||
rv = ''
|
||||
for n in range(2):
|
||||
w = bip39.wordlist_en[randbelow(2048)]
|
||||
rv += w[0].upper() + w[1:]
|
||||
s = list('!@#$%^&*-=|+~?') # opinionated
|
||||
shuffle(s)
|
||||
rv += ''.join(s[0:3])
|
||||
rv += '%04d' % randbelow(100000)
|
||||
return rv
|
||||
|
||||
async def _bip85(was):
|
||||
s = dis.save_state()
|
||||
rv = await pick_bip85_password()
|
||||
dis.restore_state(s)
|
||||
return rv
|
||||
|
||||
def _toggle_case(was):
|
||||
# undocumented
|
||||
return was.upper() if was[0].islower() else was.lower()
|
||||
|
||||
|
||||
fmsg = (KEY_F1 + ' 12 ' + KEY_F2 + ' 24 word '
|
||||
+ KEY_F3 + KEY_F4 + ' random '
|
||||
+ KEY_F5 + 'B85')
|
||||
handlers = {KEY_F1: _pick_12, KEY_F2: _pick_24, KEY_F3: _pick_dense,
|
||||
KEY_F4: _do_dumb, KEY_F6: _toggle_case, KEY_F5: _bip85}
|
||||
|
||||
return await ux_input_text(old_value, confirm_exit=True, max_len=128, scan_ok=True,
|
||||
b39_complete=True, prompt='Password', placeholder='(optional)',
|
||||
funct_keys=(fmsg, handlers))
|
||||
|
||||
class NotesMenu(MenuSystem):
|
||||
|
||||
@classmethod
|
||||
def construct(cls):
|
||||
# Dynamic menu with user-defined names of notes shown
|
||||
|
||||
news = [ MenuItem('New Note', f=cls.new_note, arg='n'),
|
||||
MenuItem('New Password', f=cls.new_note, arg='p') ]
|
||||
|
||||
if not NoteContent.count():
|
||||
rv = news + [ MenuItem('Disable Feature', f=cls.disable_notes) ]
|
||||
else:
|
||||
rv = []
|
||||
for note in NoteContent.get_all():
|
||||
rv.append(MenuItem('%d: %s' % (note.idx+1, note.title), menu=note.make_menu))
|
||||
|
||||
rv.extend(news)
|
||||
|
||||
rv.append(MenuItem('Import from File', f=None))
|
||||
return rv
|
||||
|
||||
def update_contents(self):
|
||||
# Reconstruct the list of notes on this dynamic menu, because
|
||||
# we added or changed them and are showing that same menu again.
|
||||
tmp = self.construct()
|
||||
self.replace_items(tmp)
|
||||
|
||||
@classmethod
|
||||
async def disable_notes(cls, *a):
|
||||
# they don't want feature anymore; already checked no notes in effect
|
||||
# - no need for confirm, they aren't loosing anything
|
||||
settings.remove_key('notes')
|
||||
settings.save()
|
||||
|
||||
from actions import goto_top_menu
|
||||
goto_top_menu()
|
||||
|
||||
@classmethod
|
||||
async def new_note(cls, menu, _, item):
|
||||
# Create a new note. Wizard style
|
||||
tmp = PasswordContent() if item.arg == 'p' else NoteContent()
|
||||
await tmp.edit(menu, _, item)
|
||||
|
||||
|
||||
class NoteContentBase:
|
||||
def __init__(self, json={}, idx=-1):
|
||||
# no args will make a blank record, else we are deserializing json
|
||||
for fld in self.flds:
|
||||
setattr(self, fld, json.get(fld, ''))
|
||||
self.idx = idx
|
||||
|
||||
def serialize(self):
|
||||
return {fld:getattr(self, fld, '') for fld in self.flds}
|
||||
|
||||
@classmethod
|
||||
def get_all(cls):
|
||||
# list of all notes/passwords
|
||||
rv = []
|
||||
for idx, j in enumerate(settings.get('notes', [])):
|
||||
rv.append(PasswordContent(j, idx) if 'user' in j else NoteContent(j, idx))
|
||||
return rv
|
||||
|
||||
@classmethod
|
||||
def count(cls):
|
||||
# how many do we have?
|
||||
return len(settings.get('notes', []))
|
||||
|
||||
async def delete(self, *a):
|
||||
# Remove note
|
||||
ok = await ux_confirm("Everything about this note/password will be lost.")
|
||||
if not ok:
|
||||
await ux_dramatic_pause('Aborted.', 3)
|
||||
return
|
||||
|
||||
was = list(settings.get('notes', []))
|
||||
assert self.idx >= 0
|
||||
assert self.idx < len(was)
|
||||
|
||||
del was[self.idx]
|
||||
|
||||
settings.put('notes', was)
|
||||
settings.save()
|
||||
|
||||
# go to (updated) parent menu
|
||||
the_ux.pop()
|
||||
m = the_ux.top_of_stack()
|
||||
m.update_contents()
|
||||
|
||||
await ux_dramatic_pause('Deleted.', 3)
|
||||
|
||||
async def export(self, *a):
|
||||
pass
|
||||
|
||||
async def _save_ux(self, menu):
|
||||
is_new = self.save()
|
||||
|
||||
if not is_new:
|
||||
# change our own menu (only one thing: title line)
|
||||
menu.items[0].label = '"%s"' % self.title
|
||||
|
||||
# update parent
|
||||
parent = the_ux.parent_of(menu)
|
||||
parent.update_contents()
|
||||
else:
|
||||
menu.update_contents()
|
||||
|
||||
await ux_dramatic_pause('Saved.', 3)
|
||||
|
||||
def save(self):
|
||||
was = list(settings.get('notes', []))
|
||||
if self.idx == -1:
|
||||
was.append(self.serialize())
|
||||
self.idx = len(was)-1
|
||||
is_new = True
|
||||
else:
|
||||
was[self.idx] = self.serialize()
|
||||
is_new = False
|
||||
settings.put('notes', was)
|
||||
settings.save()
|
||||
|
||||
return is_new
|
||||
|
||||
class PasswordContent(NoteContentBase):
|
||||
# "Passwords" have a few more fields and are more structured
|
||||
flds = ['title', 'user', 'password', 'site', 'misc' ]
|
||||
|
||||
async def make_menu(self, *a):
|
||||
return [
|
||||
MenuItem('"%s"' % self.title, f=self.view),
|
||||
MenuItem('View Password', f=self.view_pw),
|
||||
MenuItem('Change Password', f=self.change_pw),
|
||||
MenuItem('Send Password', f=self.send_pw),
|
||||
MenuItem('Edit', f=self.edit),
|
||||
MenuItem('Delete', f=self.delete),
|
||||
MenuItem('Export', f=self.export),
|
||||
]
|
||||
|
||||
async def view(self, *a):
|
||||
pl = len(self.password)
|
||||
m = 'Site: %s''' % self.site
|
||||
m = 'User: %s''' % self.user
|
||||
m += '\nPassword: (%d chars long)' % pl
|
||||
|
||||
if self.misc:
|
||||
m += '\nNotes:\n' + self.misc
|
||||
|
||||
await ux_show_story(m, title=self.title)
|
||||
|
||||
async def change_pw(self, *a):
|
||||
# change password
|
||||
npw = await get_a_password(self.password)
|
||||
|
||||
if npw == self.password: return
|
||||
|
||||
msg = 'Old Password:\n%s\n\nNew Password:\n%s' % (
|
||||
self.password or '<EMPTY>', npw or '<EMPTY>')
|
||||
ch = await ux_show_story(msg, title='Confirm Change')
|
||||
if ch == 'y':
|
||||
self.password = npw
|
||||
self.save()
|
||||
await ux_dramatic_pause('Saved.', 3)
|
||||
else:
|
||||
await ux_dramatic_pause('Aborted.', 3)
|
||||
|
||||
async def view_pw(self, *a):
|
||||
msg = self.password or '<EMPTY>'
|
||||
msg += '\n\nPress (1) to change, (6) to send over USB.'
|
||||
ch = await ux_show_story(msg, title='Password', escape='16')
|
||||
if ch == '1':
|
||||
await self.change_pw()
|
||||
elif ch == '6':
|
||||
await self.send_pw()
|
||||
|
||||
|
||||
async def send_pw(self, *a):
|
||||
pass
|
||||
|
||||
async def edit(self, menu, _, item):
|
||||
# Edit, also used for add new
|
||||
|
||||
title = await ux_input_text(self.title, confirm_exit=False, max_len=ONE_LINE,
|
||||
prompt='Title', placeholder='(required for menu)')
|
||||
if not title:
|
||||
return
|
||||
|
||||
# blank is OK for all other values
|
||||
|
||||
user = await ux_input_text(self.site, confirm_exit=True, max_len=ONE_LINE, scan_ok=True,
|
||||
prompt='Username', placeholder='(optional)')
|
||||
|
||||
if self.idx == -1:
|
||||
# prompt for password only on new records.
|
||||
self.password = await get_a_password(self.password)
|
||||
|
||||
site = await ux_input_text(self.site, confirm_exit=True, max_len=ONE_LINE, scan_ok=True,
|
||||
prompt='Website', placeholder='(optional)')
|
||||
|
||||
misc = await ux_input_text(self.misc, confirm_exit=True, max_len=None, scan_ok=True,
|
||||
prompt='More Notes', placeholder='(optional)')
|
||||
|
||||
if self.idx != -1:
|
||||
# confirm changes, don't for new records
|
||||
chgs = []
|
||||
if self.title != title:
|
||||
chgs.append('Title')
|
||||
if self.site != site:
|
||||
chgs.append('Site Name')
|
||||
if self.user != user:
|
||||
chgs.append('Username')
|
||||
if self.misc != misc:
|
||||
chgs.append('Other Notes')
|
||||
|
||||
if not chgs:
|
||||
await ux_dramatic_pause('No changes.', 3)
|
||||
return
|
||||
|
||||
ok = await ux_confirm("Save changes?\n- " + ('\n - '.join(chgs)))
|
||||
if not ok:
|
||||
return
|
||||
|
||||
self.title = title
|
||||
self.site = site
|
||||
self.misc = misc
|
||||
self.user = user
|
||||
|
||||
await self._save_ux(menu)
|
||||
|
||||
|
||||
class NoteContent(NoteContentBase):
|
||||
# Pure "notes" have just a title and free-form text
|
||||
flds = ['title', 'misc' ]
|
||||
|
||||
async def make_menu(self, *a):
|
||||
# details and actions for this Note
|
||||
# details and actions for this Note
|
||||
return [
|
||||
MenuItem('"%s"' % self.title, f=self.view),
|
||||
MenuItem('View Notes', f=self.view),
|
||||
#MenuItem('Send Password', f=self.send_pw),
|
||||
MenuItem('Edit', f=self.edit),
|
||||
MenuItem('Delete', f=self.delete),
|
||||
MenuItem('Export', f=self.export),
|
||||
]
|
||||
|
||||
async def view(self, *a):
|
||||
await ux_show_story(self.misc, title=self.title)
|
||||
|
||||
async def edit(self, menu, _, item):
|
||||
# Edit, also used for add new
|
||||
|
||||
title = await ux_input_text(self.title, confirm_exit=False, max_len=CHARS_W-2,
|
||||
prompt='Title', placeholder='(required for menu)')
|
||||
if not title:
|
||||
return
|
||||
|
||||
# blank is OK for all other values
|
||||
|
||||
misc = await ux_input_text(self.misc, confirm_exit=True, max_len=None, scan_ok=True,
|
||||
prompt='Your Notes', placeholder='(freeform text)')
|
||||
|
||||
if self.idx != -1:
|
||||
# confirm changes, don't for new records
|
||||
chgs = []
|
||||
if self.title != title:
|
||||
chgs.append('Title')
|
||||
if self.misc != misc:
|
||||
chgs.append('Note Text')
|
||||
|
||||
if not chgs:
|
||||
await ux_dramatic_pause('No changes.', 3)
|
||||
return
|
||||
|
||||
ok = await ux_confirm("Save changes?\n- " + ('\n - '.join(chgs)))
|
||||
if not ok:
|
||||
return
|
||||
|
||||
self.title = title
|
||||
self.misc = misc
|
||||
|
||||
await self._save_ux(menu)
|
||||
|
||||
|
||||
# EOF
|
||||
|
||||
|
||||
@ -56,6 +56,7 @@ from version import is_devmode
|
||||
# seedvault = (bool) opt-in enable seed vault feature
|
||||
# seeds = list of stored secrets for seedvault feature
|
||||
# bright = (int:0-255) LCD brightness when on battery
|
||||
# notes = (complex) Secure notes held for user
|
||||
|
||||
# Stored w/ key=00 for access before login
|
||||
# _skip_pin = hard code a PIN value (dangerous, only for debug)
|
||||
|
||||
@ -1175,7 +1175,7 @@ class PassphraseMenu(MenuSystem):
|
||||
# let them control each character
|
||||
global pp_sofar
|
||||
pw = await ux_input_text(pp_sofar, prompt="Your BIP-39 Passphrase",
|
||||
b39_complete=True, max_len=100)
|
||||
b39_complete=True, scan_ok=True, max_len=100)
|
||||
if pw is not None:
|
||||
pp_sofar = pw
|
||||
self.check_length()
|
||||
|
||||
@ -128,38 +128,35 @@ async def ux_input_numbers(val, validate_func):
|
||||
pass
|
||||
|
||||
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=True):
|
||||
prompt='Enter value', min_len=0, b39_complete=False, scan_ok=False,
|
||||
placeholder=None, funct_keys=None):
|
||||
# Get a text string.
|
||||
# - Should allow full unicode, NKDN
|
||||
# - but our font is mostly just ascii
|
||||
# - no control chars allowed either
|
||||
# - TODO: press QR -> do scan and use that text
|
||||
# - press QR -> do scan and use that text
|
||||
# - funct_keys => CTA msg, and map of Fn key to async-function which takes and returns new text
|
||||
# - TODO: regex validation for derviation paths?
|
||||
# - TODO: arrowing around
|
||||
from glob import dis
|
||||
from ux import ux_show_story
|
||||
MAX_LINES = 7
|
||||
|
||||
value = value or ''
|
||||
|
||||
dis.clear()
|
||||
|
||||
if b39_complete:
|
||||
dis.text(None, -2, KEY_TAB + " to auto-complete. " + KEY_QR + " to scan.")
|
||||
dis.text(None, -1, "CANCEL or ENTER when done.")
|
||||
|
||||
# TODO:
|
||||
# - left/right to edit in middle
|
||||
# - 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
|
||||
|
||||
|
||||
line_len = CHARS_W-2
|
||||
y = 2
|
||||
if max_len <= CHARS_W-2:
|
||||
if max_len is None:
|
||||
# whatever the max is we can support
|
||||
num_lines = MAX_LINES
|
||||
max_len = num_lines * line_len
|
||||
elif max_len <= line_len:
|
||||
# single-line or perhaps shorter value
|
||||
line_len = max_len
|
||||
num_lines = 1
|
||||
@ -170,15 +167,35 @@ async def ux_input_text(value, confirm_exit=True, hex_only=False, max_len=100,
|
||||
num_lines = 4
|
||||
else:
|
||||
# multi-line mode: just do a box for most of screen
|
||||
num_lines = 6
|
||||
line_len = CHARS_W-2
|
||||
num_lines, runt = divmod(max_len, line_len)
|
||||
if runt:
|
||||
num_lines += 1
|
||||
assert num_lines <= MAX_LINES, num_lines # too big to fit w/o scroll
|
||||
|
||||
dis.clear()
|
||||
|
||||
if funct_keys:
|
||||
msg, funct_keys = funct_keys
|
||||
dis.text(None, -2, msg, dark=True)
|
||||
|
||||
if b39_complete or scan_ok:
|
||||
msg = []
|
||||
if b39_complete:
|
||||
msg.append(KEY_TAB + " to auto-complete.")
|
||||
if scan_ok:
|
||||
msg.append(KEY_QR + " to scan.")
|
||||
dis.text(None, -1, ' '.join(msg), dark=True)
|
||||
|
||||
elif num_lines <= 2:
|
||||
# show this dumb CTA only if screen mostly blank
|
||||
dis.text(None, -1, "CANCEL or ENTER when done.", dark=True)
|
||||
|
||||
dis.text(None, y-2, prompt)
|
||||
x = dis.draw_box(None, y-1, line_len, num_lines)
|
||||
x = dis.draw_box(None, y-1, line_len, num_lines, dark=True)
|
||||
|
||||
# NOTE:
|
||||
# - x,y here are top left of entry area
|
||||
# - not allow cursor movement, always appending to end
|
||||
# - does not allow cursor movement, always appending to end
|
||||
|
||||
# no key-repeat on certain keys
|
||||
err_msg = last_err = None
|
||||
@ -199,6 +216,8 @@ async def ux_input_text(value, confirm_exit=True, hex_only=False, max_len=100,
|
||||
if not value:
|
||||
bx = 0
|
||||
n = 0
|
||||
if placeholder:
|
||||
dis.text(x, y, placeholder, dark=True)
|
||||
else:
|
||||
for n, ln_pos in enumerate(range(0, len(value), line_len)):
|
||||
ln = value[ln_pos:ln_pos+line_len]
|
||||
@ -206,9 +225,9 @@ async def ux_input_text(value, confirm_exit=True, hex_only=False, max_len=100,
|
||||
bx = len(ln)
|
||||
|
||||
# decide cursor appearance
|
||||
cur = CursorSpec(x+bx, y+n, CURSOR_SOLID)
|
||||
cur = CursorSpec(x+bx, y+n, CURSOR_OUTLINE)
|
||||
if cur.x >= x+line_len:
|
||||
# outline mode if on final possible location
|
||||
# if on final possible location, adjust over top of final char
|
||||
cur = CursorSpec(x+line_len-1, y+n, CURSOR_OUTLINE)
|
||||
|
||||
dis.show(cursor=cur)
|
||||
@ -301,6 +320,9 @@ async def ux_input_text(value, confirm_exit=True, hex_only=False, max_len=100,
|
||||
else:
|
||||
err_msg = 'Need more letters.'
|
||||
|
||||
elif funct_keys and (ch in funct_keys):
|
||||
# replace w/ function output ... might do a transform, or not
|
||||
value = await funct_keys[ch](value)
|
||||
else:
|
||||
ch = ch_remap(ch)
|
||||
if ch is not None:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user