forked from Foundation-Devices/passport-firmware
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathmenu.py
350 lines (284 loc) · 11.6 KB
/
menu.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
# SPDX-FileCopyrightText: 2020 Foundation Devices, Inc. <[email protected]>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# SPDX-FileCopyrightText: 2018 Coinkite, Inc. <coldcardwallet.com>
# SPDX-License-Identifier: GPL-3.0-only
#
# (c) Copyright 2018 by Coinkite Inc. This file is part of Coldcard <coldcardwallet.com>
# and is covered by GPLv3 license found in COPYING.
#
# menu.py - Implement an interactive menu system.
#
import gc
import utime
from display import Display, FontSmall
from uasyncio import sleep_ms
from ux import KeyInputHandler, the_ux, ux_shutdown
def start_chooser(chooser, title='Select', show_checks=True):
# get which one to show as selected, list of choices, and fcn to call after
selected, choices, setter = chooser()
def picked(menu, picked, xx_self):
menu.chosen = picked
menu.show()
await sleep_ms(200) # visual feedback that we changed it
setter(picked, choices[picked])
the_ux.pop()
# make a new menu, just for the choices
m = MenuSystem([MenuItem(c, f=picked, has_submenu=show_checks) for c in choices],
chooser_mode=show_checks, chosen=selected, title=title)
the_ux.push(m)
class MenuItem:
def __init__(self, label, menu=None, f=None, chooser=None, arg=None, predicate=None, menu_title='Passport', action=None, has_submenu=True):
self.label = label
self.arg = arg
self.menu_title = menu_title
if menu:
self.next_menu = menu
if f:
self.next_function = f
if chooser:
self.chooser = chooser
if predicate:
self.predicate = predicate
if action:
self.action = action
self.has_submenu = has_submenu # Used to determine whether to show the > wedge on the right side
async def activate(self, menu, idx):
if getattr(self, 'chooser', None):
start_chooser(self.chooser, title=self.menu_title)
else:
# Run action if any (this is some side effect, like setting the current account when entering a menu)
action = getattr(self, 'action', None)
if action:
action(label=self.label, arg=self.arg, menu_title=self.menu_title, index=idx)
# nesting menus, and functions and so on.
f = getattr(self, 'next_function', None)
if f:
rv = await f(menu, idx, self)
if isinstance(rv, MenuSystem):
# XXX the function should do this itself
# go to new menu
the_ux.replace(rv)
m = getattr(self, 'next_menu', None)
if callable(m):
m = await m(menu, idx, self)
if isinstance(m, list):
m = MenuSystem(m, title=self.menu_title)
if m:
the_ux.push(m)
class MenuSystem:
def __init__(self, menu_items, chooser_mode=False, chosen=None, should_cont=None, space_indicators=False, title="Passport"):
self.should_continue = should_cont or (lambda: True)
self.original_items = menu_items
self.replace_items(menu_items)
self.space_indicators = space_indicators
self.chooser_mode = chooser_mode
self.chosen = chosen
self.title = title
self.input = KeyInputHandler(down='udxy', up='udxy', repeat_delay=250, repeat_speed=10)
self.shutdown_btn_enabled = False
self.turbo = None # We rely on this being 3 states: None, False, True
# Setup font
self.font = FontSmall
# number of full lines per screen
self.max_lines = (
Display.HEIGHT - Display.HEADER_HEIGHT - Display.FOOTER_HEIGHT) // self.font.leading
if chosen is not None:
self.goto_idx(chosen)
# subclasses: override us
#
def late_draw(self, dis):
pass
def early_draw(self, dis):
pass
# Submenus can override this
def update_contents(self):
self.replace_items(self.original_items, True)
def replace_items(self, menu_items, keep_position=False):
# only safe to keep position if you know number of items isn't changing
if not keep_position:
self.cursor = 0
self.ypos = 0
self.items = [m for m in menu_items if not getattr(
m, 'predicate', None) or m.predicate()]
self.count = len(self.items)
# If we removed items, make sure the cursor is still visible
while self.cursor >= self.count:
self.cursor -= 1
def show(self):
from common import dis, system
#
# Redraw the menu.
#
dis.clear()
# subclass hook
self.early_draw(dis)
# Header
wm = True if self.title == None else False
dis.draw_header(self.title)
menu_item_height = self.font.leading
menu_item_left = 6
sel_w, sel_h = dis.icon_size('selected')
if self.chooser_mode:
menu_item_left += sel_w
show_scrollbar = True if self.count > self.max_lines else False
x, y = (menu_item_left, Display.HEADER_HEIGHT)
for n in range(self.ypos + self.max_lines + 1):
if n+self.ypos >= self.count:
break
menu_item = self.items[n+self.ypos]
msg = menu_item.label
is_sel = (self.cursor == n+self.ypos)
if is_sel:
wedge_w, wedge_h = dis.icon_size('wedge')
dis.dis.fill_rect(0, y, Display.WIDTH, menu_item_height - 1, 1)
dis.text(x, y + 2, msg, font=self.font, invert=1)
if not self.chooser_mode and menu_item.has_submenu:
wedge_offset = 12 if show_scrollbar else 6
icon_x = dis.WIDTH - wedge_w - wedge_offset
dis.dis.fill_rect(
icon_x - 2,
y,
Display.WIDTH - (icon_x - 2),
menu_item_height - 1,
1)
dis.icon(
icon_x,
y + (menu_item_height - wedge_h) // 2,
'wedge',
invert=1)
else:
dis.text(x, y + 2, msg, font=self.font)
if msg[0] == ' ' and self.space_indicators:
dis.icon(x-2, y + 11, 'space', invert=is_sel)
if self.chooser_mode and self.chosen is not None and (n+self.ypos) == self.chosen:
dis.icon(2, y + 6, 'selected', invert=is_sel)
y += menu_item_height
if y > Display.HEIGHT - Display.FOOTER_HEIGHT:
break
# subclass hook
self.late_draw(dis)
if show_scrollbar:
dis.scrollbar(self.ypos / self.count, self.max_lines / self.count)
self.shutdown_btn_enabled = the_ux.is_top_level()
left_btn = 'SHUTDOWN' if self.shutdown_btn_enabled else 'BACK'
dis.draw_footer(left_btn, 'SELECT', self.input.is_pressed('x'),
self.input.is_pressed('y'))
dis.show()
# We only want to turn it off once rather than whenever it's False, so we
# set to None to avoid turning turbo off again.
if self.turbo == False:
system.turbo(False)
self.turbo = None
def down(self):
if self.cursor < self.count-1:
self.cursor += 1
if self.cursor - self.ypos > (self.max_lines-1):
self.ypos += 1
def up(self):
if self.cursor > 0:
self.cursor -= 1
if self.cursor < self.ypos:
self.ypos -= 1
def top(self):
self.cursor = 0
self.ypos = 0
def goto_n(self, n):
# goto N from top of (current) screen
# change scroll only if needed to make it visible
self.cursor = max(min(n + self.ypos, self.count-1), 0)
self.ypos = max(self.cursor - n, 0)
def goto_idx(self, n):
# skip to any item, force cursor near middle of screen
# NOTE: If we get a string error here, it probably means we have
# passed the title to a MenuSystem() call as the second parameter instead of as a named parameter
n = self.count-1 if n >= self.count else n
n = 0 if n < 0 else n
self.cursor = n
if n < self.max_lines - 1:
self.ypos = 0
else:
self.ypos = n - 2
def page(self, n):
# relative page dn/up
if n == 1:
for i in range(self.max_lines):
self.down()
else:
for i in range(self.max_lines):
self.up()
# events
def on_cancel(self):
# override me
if the_ux.pop():
# top of stack (main top-level menu)
self.top()
async def activate(self, idx):
# Activate a specific choice in our menu.
if idx is None:
# "go back" or cancel or something
if self.shutdown_btn_enabled:
if not self.input.kcode_imminent():
await ux_shutdown()
else:
self.on_cancel()
else:
if idx >= 0 and idx < self.count:
ch = self.items[idx]
await ch.activate(self, idx)
self.input.reset()
async def interact(self):
# Only public entry point: do stuff.
while self.should_continue() and the_ux.top_of_stack() == self:
ch = await self.wait_choice()
gc.collect()
await self.activate(ch)
async def wait_choice(self):
# Wait until a menu choice is picked; let them move around
# the menu, keep redrawing it and so on.
key = None
while 1:
# Give the menu predicates another chance to run in case they changed
self.update_contents()
self.show()
start = utime.ticks_ms()
event = None
while True:
event = await self.input.get_event()
if event != None:
break
# Redraw the display if no menu input has occurred
# for a while. Gives the battery icon a chance to update.
end = utime.ticks_ms()
if end - start >= 60000:
event = (None, None)
break
key, event_type = event
# print('key={} event_type={}'.format(key, event_type))
if event_type == 'down' or event_type == 'repeat':
if event_type == 'down':
from common import system
system.turbo(True)
self.turbo = True
if not self.input.kcode_imminent():
if key == 'u':
self.up()
elif key == 'd':
self.down()
if event_type == 'up':
self.turbo = False # We set to False here, but actually turn off after rendering
if self.input.kcode_complete():
self.input.kcode_reset();
# print('SHOW SECRET EXTRAS MENU!')
from flow import ExtrasMenu
menu_item = MenuItem('Extras', ExtrasMenu, menu_title='Extras')
await menu_item.activate(self, 0)
return -1 # So that the caller does nothing
elif not self.input.kcode_imminent():
if key == 'y':
# selected
return self.cursor
elif key == 'x':
# abort/nothing selected/back out?
return None
# EOF