# (c) Copyright 2018 by Coinkite Inc. This file is part of Coldcard # and is covered by GPLv3 license found in COPYING. # # ux.py - UX/UI related helper functions # # NOTE: do not import from main at top. from uasyncio import sleep_ms from uasyncio.queues import QueueEmpty import utime, gc DEFAULT_IDLE_TIMEOUT = const(4*3600) # (seconds) 4 hours # This signals the need to switch from current # menu (or whatever) to show something new. The # stack has already been updated, but the old # top-of-stack code was waiting for a key event. # class AbortInteraction(Exception): pass class UserInteraction: def __init__(self): self.stack = [] def top_of_stack(self): return self.stack[-1] if self.stack else None def reset(self, new_ux): self.stack.clear() gc.collect() self.push(new_ux) async def interact(self): # this is called inside a while(1) all the time # - execute top of stack item try: await self.stack[-1].interact() except AbortInteraction: pass def push(self, new_ux): self.stack.append(new_ux) def replace(self, new_ux): old = self.stack.pop() del old self.stack.append(new_ux) def pop(self): if len(self.stack) < 2: # top of stack, do nothing return True old = self.stack.pop() del old # Singleton. User interacts with this "menu" stack. the_ux = UserInteraction() def ux_clear_keys(): # flush any pending keypresses from main import numpad try: while 1: ch = numpad.get_nowait() if ch == numpad.ABORT_KEY: raise AbortInteraction except QueueEmpty: return async def ux_wait_keyup(expected): # Wait for single keypress in 'expected' set, return it # no visual feedback, no escape from main import numpad armed = None while 1: ch = await numpad.get() if ch == numpad.ABORT_KEY: raise AbortInteraction if len(ch) > 1: # multipress continue if ch not in expected: # unwanted continue if ch == '' and armed: return armed armed = ch def ux_poll_once(expected='x'): # non-blocking check if key is pressed # - ignore and suppress any key not in expected # - responds to key down only # - eats any existing key presses from main import numpad while 1: try: ch = numpad.key_pressed while not ch: ch = numpad.get_nowait() if ch == numpad.ABORT_KEY: raise AbortInteraction except QueueEmpty: return None for c in ch: if c in expected: return c 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 main import numpad armed = None while 1: rep_delay = numpad.repeat_delay if not self.num_repeats else 100 so_far = 0 while 1: try: # Poll for an event ch = numpad.get_nowait() break except QueueEmpty: so_far += 5 await sleep_ms(5) if self.last_key and numpad.key_pressed == self.last_key: if so_far >= rep_delay: self.num_repeats += 1 return self.last_key continue 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_press_release(need_release='xy', key_repeat=None): # Wait for single char press event, # except for need_release keys, which must be released before they # are sent as events, and no corresponding release event (already consumed). # from main import numpad # never do key-repeat on keys that need "ups" if key_repeat and key_repeat in need_release: key_repeat = None armed = None while 1: if key_repeat and numpad.key_pressed == key_repeat: await sleep_ms(100) # key repeat-rate, also key-repeat delay time if numpad.key_pressed == key_repeat: return key_repeat ch = await numpad.get() if ch == numpad.ABORT_KEY: raise AbortInteraction if len(ch) > 1: # multipress: cancel press/release cycle and be a keyup # for other keys. armed = None continue if ch == '': if armed: return armed elif ch in need_release: armed = ch else: return ch async def ux_all_up(): # wait until all keys are released from main import numpad while 1: ch = await numpad.get() if ch == numpad.ABORT_KEY: raise AbortInteraction if ch == '': return # how many characters can we fit on each line? # (using FontSmall) CH_PER_W = const(17) def word_wrap(ln, w): while ln: sp = ln.rfind(' ', 0, w) if sp == -1: # bad-break the line sp = min(len(ln), w) nsp = sp else: nsp = sp+1 left = ln[0:sp] ln = ln[nsp:] if len(left) + 1 + len(ln) <= CH_PER_W: left = left + ' ' + ln ln = '' yield left async def ux_show_story(msg, title=None, escape=None): # show a big long string, and wait for XY to continue # - returns character used to get out (X or Y) # - can accept other chars to 'escape' as well. from main import dis, numpad from display import FontLarge assert not numpad.disabled # probably inside a CardSlot context lines = [] if title: # kinda weak rendering but it works. lines.append('\x01' + title) #lines.append('') for ln in msg.split('\n'): if len(ln) > CH_PER_W: lines.extend(word_wrap(ln, CH_PER_W)) else: # ok if empty string, just a blank line lines.append(ln) # trim blank lines at end, add our own marker while not lines[-1]: lines = lines[:-1] lines.append('EOT') #print("story:\n\n\"" + '"\n"'.join(lines)) #lines[0] = '111111111121234567893' top = 0 H = 5 ch = None pr = PressRelease() while 1: # redraw dis.clear() y=0 for ln in lines[top:top+H]: if ln == 'EOT': dis.hline(y+3) elif ln and ln[0] == '\x01': dis.text(0, y, ln[1:], FontLarge) y += 21 else: dis.text(0, y, ln) y += 13 dis.scroll_bar(top / len(lines)) dis.show() # wait to do something ch = await pr.wait() if escape and (ch == escape or ch in escape): # allow another way out for some usages return ch elif ch in 'xy': return ch elif ch == '0': top = 0 elif ch == '5': top = max(0, top-1) elif ch == '8': top = min(len(lines)-2, top+1) async def idle_logout(): from main import numpad, settings while 1: await sleep_ms(250) # they may have changed setting recently timeout = settings.get('idle_to', DEFAULT_IDLE_TIMEOUT)*1000 # ms if timeout == 0: continue now = utime.ticks_ms() if not numpad.last_event_time: continue if now > numpad.last_event_time + timeout: # do a logout now. print("Idle timeout now!") import callgate callgate.show_logout() return async def ux_confirm(msg): # confirmation screen, with stock title and Y=of course. resp = await ux_show_story("Are you SURE ?!?\n\n" + msg) return resp == 'y' async def ux_dramatic_pause(msg, seconds): from main import dis # show a full-screen msg, with a dramatic pause + progress bar n = seconds * 8 dis.fullscreen(msg) for i in range(n): dis.progress_bar_show(i/n) await sleep_ms(125) ux_clear_keys() def show_fatal_error(msg): # show a multi-line error message, over some kinda "fatal" banner from main import dis from display import FontTiny dis.clear() lines = msg.split('\n')[-6:] dis.text(None, 1, '>>>> Yikes!! <<<<') y = 13+2 for num, ln in enumerate(lines): ln = ln.strip() if ln[0:6] == 'File "': # convert: File "main.py", line 63, in interact # into: main.py:63 interact ln = ln[6:].replace('", line ', ':').replace(', in ', ' ') dis.text(0, y + (num*8), ln, FontTiny) dis.show() async def ux_aborted(): # us this when dangerous action is not performed due to confirmations await ux_dramatic_pause('Aborted.', 2) return None def restore_menu(): # redraw screen contents after distrupting it w/ non-ux things (usb upload) m = the_ux.top_of_stack() if hasattr(m, 'show'): m.show() def abort_and_goto(m): from main import numpad the_ux.reset(m) numpad.abort_ux() # EOF