mirror of
https://github.com/SeedSigner/seedsigner.git
synced 2026-10-05 15:08:25 +00:00
1084 lines
42 KiB
Python
1084 lines
42 KiB
Python
import time
|
|
|
|
from dataclasses import dataclass
|
|
from PIL import Image, ImageDraw, ImageColor
|
|
from typing import Any, List, Tuple
|
|
from seedsigner.gui.keyboard import Keyboard, TextEntryDisplay
|
|
from seedsigner.gui.renderer import Renderer
|
|
|
|
from seedsigner.models.threads import BaseThread
|
|
from seedsigner.models.encode_qr import EncodeQR
|
|
from seedsigner.models.settings import Settings, SettingsConstants
|
|
|
|
from ..components import (FontAwesomeIconConstants, GUIConstants, BaseComponent, Button, Icon, IconButton, LargeIconButton, SeedSignerCustomIconConstants, TopNav,
|
|
TextArea, load_image)
|
|
|
|
from seedsigner.hardware.buttons import HardwareButtonsConstants, HardwareButtons
|
|
|
|
|
|
# Must be huge numbers to avoid conflicting with the selected_button returned by the
|
|
# screens with buttons.
|
|
RET_CODE__BACK_BUTTON = 1000
|
|
RET_CODE__POWER_BUTTON = 1001
|
|
|
|
|
|
|
|
@dataclass
|
|
class BaseScreen(BaseComponent):
|
|
def __post_init__(self):
|
|
super().__post_init__()
|
|
|
|
self.hw_inputs = HardwareButtons.get_instance()
|
|
|
|
# Implementation classes can add their own BaseThread to run in parallel with the
|
|
# main execution thread.
|
|
self.threads: List[BaseThread] = []
|
|
|
|
# Implementation classes can add additional BaseComponent-derived objects to the
|
|
# list. They'll be called to `render()` themselves in BaseScreen._render().
|
|
self.components: List[BaseComponent] = []
|
|
|
|
# Implementation classes can add PIL.Image objs here. Format is a tuple of the
|
|
# Image and its (x,y) paste coords.
|
|
self.paste_images: List[Tuple] = []
|
|
|
|
# Tracks position on scrollable pages, determines which elements are visible.
|
|
self.scroll_y = 0
|
|
|
|
|
|
def display(self) -> Any:
|
|
try:
|
|
with self.renderer.lock:
|
|
self._render()
|
|
self.renderer.show_image()
|
|
|
|
for t in self.threads:
|
|
t.start()
|
|
|
|
return self._run()
|
|
except Exception as e:
|
|
repr(e)
|
|
raise e
|
|
finally:
|
|
for t in self.threads:
|
|
t.stop()
|
|
|
|
|
|
def clear_screen(self):
|
|
# Clear the whole canvas
|
|
self.image_draw.rectangle(
|
|
(0, 0, self.canvas_width, self.canvas_height),
|
|
fill=0,
|
|
)
|
|
|
|
|
|
def _render(self):
|
|
self.clear_screen()
|
|
|
|
# TODO: Check self.scroll_y and only render visible elements
|
|
for component in self.components:
|
|
component.render()
|
|
|
|
for img, coords in self.paste_images:
|
|
self.canvas.paste(img, coords)
|
|
|
|
|
|
def _run_callback(self):
|
|
"""
|
|
Optional implementation step that's called during each _run() loop.
|
|
|
|
Loop will continue if it returns None.
|
|
If it returns a value, the Screen will exit and relay that return value to
|
|
its parent View.
|
|
"""
|
|
pass
|
|
|
|
|
|
def _run(self):
|
|
"""
|
|
Screen can run on its own until it returns a final exit input from the user.
|
|
|
|
For example: A basic menu screen where the user can key up and down. The
|
|
Screen can handle the UI updates to light up the currently selected menu item
|
|
on its own. Only when the user clicks to make a selection would run() exit
|
|
and returns the selected option.
|
|
|
|
But an alternate use case returns immediately after each user input so the
|
|
View can update its controlling logic accordingly (e.g. as the user joysticks
|
|
over different letters in the keyboard UI, we need to make matching changes
|
|
to the list of mnemonic seed words that match the new letter).
|
|
|
|
In this case, it would be called repeatedly in a loop:
|
|
* run() and wait for it to handle user input
|
|
* run() exits and returns the user input (e.g. KEY_UP)
|
|
* View updates its state of the world accordingly
|
|
* loop and call run() again
|
|
"""
|
|
raise Exception("Must implement in a child class")
|
|
|
|
|
|
|
|
class LoadingScreenThread(BaseThread):
|
|
def __init__(self, text: str = None):
|
|
super().__init__()
|
|
self.text =text
|
|
|
|
|
|
def run(self):
|
|
renderer: Renderer = Renderer.get_instance()
|
|
|
|
center_image = load_image("btc_logo_60x60.png")
|
|
orbit_gap = 2*GUIConstants.COMPONENT_PADDING
|
|
bounding_box = (
|
|
int((renderer.canvas_width - center_image.width)/2 - orbit_gap),
|
|
int((renderer.canvas_height - center_image.height)/2 - orbit_gap),
|
|
int((renderer.canvas_width + center_image.width)/2 + orbit_gap),
|
|
int((renderer.canvas_height + center_image.height)/2 + orbit_gap),
|
|
)
|
|
position = 0
|
|
arc_sweep = 45
|
|
arc_color = "#ff9416"
|
|
arc_trailing_color = "#80490b"
|
|
|
|
# Need to flush the screen
|
|
with renderer.lock:
|
|
renderer.draw.rectangle((0, 0, renderer.canvas_width, renderer.canvas_height), fill=GUIConstants.BACKGROUND_COLOR)
|
|
renderer.canvas.paste(center_image, (bounding_box[0] + orbit_gap, bounding_box[1] + orbit_gap))
|
|
|
|
if self.text:
|
|
TextArea(
|
|
text=self.text,
|
|
font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE,
|
|
screen_y=int((renderer.canvas_height - bounding_box[3])/2),
|
|
).render()
|
|
|
|
while self.keep_running:
|
|
with renderer.lock:
|
|
# Render leading arc
|
|
renderer.draw.arc(
|
|
bounding_box,
|
|
start=position,
|
|
end=position + arc_sweep,
|
|
fill=arc_color,
|
|
width=GUIConstants.COMPONENT_PADDING
|
|
)
|
|
|
|
# Render trailing arc
|
|
renderer.draw.arc(
|
|
bounding_box,
|
|
start=position - arc_sweep,
|
|
end=position,
|
|
fill=arc_trailing_color,
|
|
width=GUIConstants.COMPONENT_PADDING
|
|
)
|
|
|
|
# Erase previous trailing arc leading arc
|
|
renderer.draw.arc(
|
|
bounding_box,
|
|
start=position - 2*arc_sweep,
|
|
end=position - arc_sweep,
|
|
fill=GUIConstants.BACKGROUND_COLOR,
|
|
width=GUIConstants.COMPONENT_PADDING
|
|
)
|
|
|
|
renderer.show_image()
|
|
position += arc_sweep
|
|
|
|
|
|
|
|
@dataclass
|
|
class BaseTopNavScreen(BaseScreen):
|
|
top_nav_icon_name: str = None
|
|
top_nav_icon_color: str = None
|
|
title: str = "Screen Title"
|
|
title_font_size: int = GUIConstants.TOP_NAV_TITLE_FONT_SIZE
|
|
show_back_button: bool = True
|
|
show_power_button: bool = False
|
|
|
|
def __post_init__(self):
|
|
super().__post_init__()
|
|
self.top_nav = TopNav(
|
|
icon_name=self.top_nav_icon_name,
|
|
icon_color=self.top_nav_icon_color,
|
|
text=self.title,
|
|
font_size=self.title_font_size,
|
|
width=self.canvas_width,
|
|
height=GUIConstants.TOP_NAV_HEIGHT,
|
|
show_back_button=self.show_back_button,
|
|
show_power_button=self.show_power_button,
|
|
)
|
|
self.is_input_in_top_nav = False
|
|
|
|
self.components.append(self.top_nav)
|
|
|
|
|
|
def _run(self):
|
|
while True:
|
|
if not self.top_nav.show_back_button and not self.top_nav.show_power_button:
|
|
# There's no navigation away from this screen; nothing to do here
|
|
time.sleep(0.1)
|
|
continue
|
|
|
|
user_input = self.hw_inputs.wait_for(
|
|
HardwareButtonsConstants.ALL_KEYS,
|
|
check_release=True,
|
|
release_keys=HardwareButtonsConstants.KEYS__ANYCLICK
|
|
)
|
|
|
|
with self.renderer.lock:
|
|
if not self.top_nav.is_selected and user_input in [
|
|
HardwareButtonsConstants.KEY_LEFT,
|
|
HardwareButtonsConstants.KEY_UP
|
|
]:
|
|
self.top_nav.is_selected = True
|
|
self.top_nav.render_buttons()
|
|
|
|
elif self.top_nav.is_selected and user_input in [
|
|
HardwareButtonsConstants.KEY_DOWN,
|
|
HardwareButtonsConstants.KEY_RIGHT
|
|
]:
|
|
self.top_nav.is_selected = False
|
|
self.top_nav.render_buttons()
|
|
|
|
elif self.top_nav.is_selected and user_input in HardwareButtonsConstants.KEYS__ANYCLICK:
|
|
return self.top_nav.selected_button
|
|
|
|
else:
|
|
# Nothing to do with this input
|
|
continue
|
|
|
|
# Write the screen updates
|
|
self.renderer.show_image()
|
|
|
|
|
|
|
|
@dataclass
|
|
class ButtonListScreen(BaseTopNavScreen):
|
|
button_data: list = None # list can be a mix of str or tuple(label: str, icon_name: str)
|
|
selected_button: int = 0
|
|
is_button_text_centered: bool = True
|
|
is_bottom_list: bool = False
|
|
button_font_name: str = GUIConstants.BUTTON_FONT_NAME
|
|
button_font_size: int = GUIConstants.BUTTON_FONT_SIZE
|
|
button_selected_color: str = GUIConstants.ACCENT_COLOR
|
|
|
|
# Params for version of list used for Settings
|
|
Button_cls = Button
|
|
checked_buttons: List[int] = None
|
|
|
|
|
|
def __post_init__(self):
|
|
super().__post_init__()
|
|
button_height = GUIConstants.BUTTON_HEIGHT
|
|
if len(self.button_data) == 1:
|
|
button_list_height = button_height
|
|
else:
|
|
button_list_height = (len(self.button_data) * button_height) + (GUIConstants.COMPONENT_PADDING * (len(self.button_data) - 1))
|
|
|
|
if self.is_bottom_list:
|
|
button_list_y = self.canvas_height - (button_list_height + GUIConstants.EDGE_PADDING)
|
|
else:
|
|
button_list_y = self.top_nav.height + int((self.canvas_height - self.top_nav.height - button_list_height) / 2)
|
|
|
|
self.has_scroll_arrows = False
|
|
if button_list_y < self.top_nav.height:
|
|
# The button list is too long; force it to run off the bottom of the screen.
|
|
button_list_y = self.top_nav.height
|
|
self.has_scroll_arrows = True
|
|
|
|
self.buttons: List[Button] = []
|
|
for i, button_label in enumerate(self.button_data):
|
|
icon_name = None
|
|
icon_color = None
|
|
right_icon_name = None
|
|
button_label_color = None
|
|
|
|
# TODO: Define an actual class for button_data?
|
|
if type(button_label) == tuple:
|
|
if len(button_label) == 2:
|
|
(button_label, icon_name) = button_label
|
|
icon_color = GUIConstants.BUTTON_FONT_COLOR
|
|
|
|
elif len(button_label) == 3:
|
|
(button_label, icon_name, icon_color) = button_label
|
|
|
|
elif len(button_label) == 4:
|
|
(button_label, icon_name, icon_color, button_label_color) = button_label
|
|
|
|
elif len(button_label) == 5:
|
|
(button_label, icon_name, icon_color, button_label_color, right_icon_name) = button_label
|
|
|
|
button_kwargs = dict(
|
|
text=button_label,
|
|
icon_name=icon_name,
|
|
icon_color=icon_color if icon_color else GUIConstants.BUTTON_FONT_COLOR,
|
|
is_icon_inline=True,
|
|
right_icon_name=right_icon_name,
|
|
screen_x=GUIConstants.EDGE_PADDING,
|
|
screen_y=button_list_y + i * (button_height + GUIConstants.LIST_ITEM_PADDING),
|
|
width=self.canvas_width - (2 * GUIConstants.EDGE_PADDING),
|
|
height=button_height,
|
|
is_text_centered=self.is_button_text_centered,
|
|
font_name=self.button_font_name,
|
|
font_size=self.button_font_size,
|
|
font_color=button_label_color if button_label_color else GUIConstants.BUTTON_FONT_COLOR,
|
|
selected_color=self.button_selected_color
|
|
)
|
|
if self.checked_buttons and i in self.checked_buttons:
|
|
button_kwargs["is_checked"] = True
|
|
button = self.Button_cls(**button_kwargs)
|
|
self.buttons.append(button)
|
|
|
|
if self.has_scroll_arrows:
|
|
self.arrow_half_width = 10
|
|
self.cur_scroll_y = 0
|
|
self.up_arrow_img = Image.new("RGBA", size=(2 * self.arrow_half_width, 8), color="black")
|
|
self.up_arrow_img_y = self.top_nav.height - 12
|
|
arrow_draw = ImageDraw.Draw(self.up_arrow_img)
|
|
arrow_draw.line((self.arrow_half_width, 1, 0, 7), fill=GUIConstants.BUTTON_FONT_COLOR)
|
|
arrow_draw.line((self.arrow_half_width, 1, 2 * self.arrow_half_width, 7), fill=GUIConstants.BUTTON_FONT_COLOR)
|
|
|
|
self.down_arrow_img = Image.new("RGBA", size=(2 * self.arrow_half_width, 8), color="black")
|
|
self.down_arrow_img_y = self.canvas_height - 16 + 2
|
|
arrow_draw = ImageDraw.Draw(self.down_arrow_img)
|
|
center_x = int(self.canvas_width / 2)
|
|
arrow_draw.line((self.arrow_half_width, 7, 0, 1), fill=GUIConstants.BUTTON_FONT_COLOR)
|
|
arrow_draw.line((self.arrow_half_width, 7, 2 * self.arrow_half_width, 1), fill=GUIConstants.BUTTON_FONT_COLOR)
|
|
|
|
cur_selected_button = self.buttons[self.selected_button]
|
|
cur_selected_button.is_selected = True
|
|
if self.has_scroll_arrows:
|
|
frame_scroll = self.buttons[0].screen_y - cur_selected_button.screen_y
|
|
for button in self.buttons:
|
|
button.scroll_y -= frame_scroll
|
|
|
|
|
|
def _render(self):
|
|
super()._render()
|
|
self._render_visible_buttons()
|
|
|
|
|
|
def _render_visible_buttons(self):
|
|
if self.has_scroll_arrows:
|
|
self._render_up_arrow()
|
|
self._render_down_arrow()
|
|
|
|
for i, button in enumerate(self.buttons):
|
|
if not self.has_scroll_arrows:
|
|
button.render()
|
|
continue
|
|
|
|
button_position_y = button.screen_y - button.scroll_y
|
|
if button_position_y >= self.top_nav.height and button_position_y < self.down_arrow_img_y:
|
|
if i == 0:
|
|
# We rendered the top button; no more to scroll up for.
|
|
self._hide_up_arrow()
|
|
|
|
if i == len(self.buttons) - 1:
|
|
# We just pulled up the last button; no more to scroll down for.
|
|
self._hide_down_arrow()
|
|
|
|
# Render the button after the arrows to cover up overlap
|
|
button.render()
|
|
|
|
|
|
def _render_up_arrow(self):
|
|
self.canvas.paste(self.up_arrow_img, (int(self.canvas_width / 2) - self.arrow_half_width, self.up_arrow_img_y))
|
|
|
|
def _render_down_arrow(self):
|
|
self.canvas.paste(self.down_arrow_img, (int(self.canvas_width / 2) - self.arrow_half_width, self.down_arrow_img_y))
|
|
|
|
def _hide_up_arrow(self):
|
|
self.image_draw.rectangle(
|
|
(
|
|
int(self.canvas_width / 2) - self.arrow_half_width, self.up_arrow_img_y,
|
|
int(self.canvas_width / 2) + self.arrow_half_width, self.up_arrow_img_y + self.up_arrow_img.height
|
|
),
|
|
fill="black"
|
|
)
|
|
|
|
def _hide_down_arrow(self):
|
|
self.image_draw.rectangle(
|
|
(
|
|
int(self.canvas_width / 2) - self.arrow_half_width, self.down_arrow_img_y,
|
|
int(self.canvas_width / 2) + self.arrow_half_width, self.down_arrow_img_y + self.down_arrow_img.height
|
|
),
|
|
fill="black"
|
|
)
|
|
|
|
|
|
def _run(self):
|
|
while True:
|
|
ret = self._run_callback()
|
|
if ret is not None:
|
|
return ret
|
|
|
|
user_input = self.hw_inputs.wait_for(
|
|
[
|
|
HardwareButtonsConstants.KEY_UP,
|
|
HardwareButtonsConstants.KEY_DOWN,
|
|
HardwareButtonsConstants.KEY_LEFT,
|
|
HardwareButtonsConstants.KEY_RIGHT,
|
|
] + HardwareButtonsConstants.KEYS__ANYCLICK,
|
|
check_release=True,
|
|
release_keys=HardwareButtonsConstants.KEYS__ANYCLICK
|
|
)
|
|
|
|
with self.renderer.lock:
|
|
if not self.top_nav.is_selected and (
|
|
user_input == HardwareButtonsConstants.KEY_LEFT or (
|
|
user_input == HardwareButtonsConstants.KEY_UP and self.selected_button == 0
|
|
)
|
|
):
|
|
# SHORTCUT to escape long menu screens!
|
|
# OR keyed UP from the top of the list.
|
|
# Move selection up to top_nav
|
|
# Only move navigation up there if there's something to select
|
|
if self.top_nav.show_back_button or self.top_nav.show_power_button:
|
|
self.buttons[self.selected_button].is_selected = False
|
|
self.buttons[self.selected_button].render()
|
|
|
|
self.top_nav.is_selected = True
|
|
self.top_nav.render_buttons()
|
|
|
|
elif user_input == HardwareButtonsConstants.KEY_UP:
|
|
if self.top_nav.is_selected:
|
|
# Can't go up any further
|
|
pass
|
|
else:
|
|
cur_selected_button: Button = self.buttons[self.selected_button]
|
|
self.selected_button -= 1
|
|
next_selected_button: Button = self.buttons[self.selected_button]
|
|
cur_selected_button.is_selected = False
|
|
next_selected_button.is_selected = True
|
|
if self.has_scroll_arrows and next_selected_button.screen_y - next_selected_button.scroll_y + next_selected_button.height < self.top_nav.height:
|
|
# Selected a Button that's off the top of the screen
|
|
frame_scroll = cur_selected_button.screen_y - next_selected_button.screen_y
|
|
for button in self.buttons:
|
|
button.scroll_y -= frame_scroll
|
|
self._render_visible_buttons()
|
|
else:
|
|
cur_selected_button.render()
|
|
next_selected_button.render()
|
|
|
|
elif user_input == HardwareButtonsConstants.KEY_DOWN or (
|
|
self.top_nav.is_selected and user_input == HardwareButtonsConstants.KEY_RIGHT
|
|
):
|
|
if self.selected_button == len(self.buttons) - 1:
|
|
# Already at the bottom of the list. Nowhere to go. But may need
|
|
# to re-render if we're returning from top_nav; otherwise skip
|
|
# this update loop.
|
|
if not self.top_nav.is_selected:
|
|
continue
|
|
|
|
if self.top_nav.is_selected:
|
|
self.top_nav.is_selected = False
|
|
self.top_nav.render_buttons()
|
|
|
|
cur_selected_button = None
|
|
next_selected_button = self.buttons[self.selected_button]
|
|
next_selected_button.is_selected = True
|
|
|
|
else:
|
|
cur_selected_button: Button = self.buttons[self.selected_button]
|
|
self.selected_button += 1
|
|
next_selected_button: Button = self.buttons[self.selected_button]
|
|
cur_selected_button.is_selected = False
|
|
next_selected_button.is_selected = True
|
|
|
|
if self.has_scroll_arrows and (
|
|
next_selected_button.screen_y - next_selected_button.scroll_y + next_selected_button.height > self.down_arrow_img_y
|
|
):
|
|
# Selected a Button that's off the bottom of the screen
|
|
frame_scroll = next_selected_button.screen_y - cur_selected_button.screen_y
|
|
for button in self.buttons:
|
|
button.scroll_y += frame_scroll
|
|
self._render_visible_buttons()
|
|
else:
|
|
if cur_selected_button:
|
|
cur_selected_button.render()
|
|
next_selected_button.render()
|
|
|
|
elif user_input in HardwareButtonsConstants.KEYS__ANYCLICK:
|
|
if self.top_nav.is_selected:
|
|
return self.top_nav.selected_button
|
|
return self.selected_button
|
|
|
|
# Write the screen updates
|
|
self.renderer.show_image()
|
|
|
|
|
|
|
|
@dataclass
|
|
class LargeButtonScreen(BaseTopNavScreen):
|
|
button_data: list = None # list can be a mix of str or tuple(label: str, icon_name: str)
|
|
button_font_name: str = GUIConstants.BUTTON_FONT_NAME
|
|
button_font_size: int = 20
|
|
button_selected_color: str = GUIConstants.ACCENT_COLOR
|
|
selected_button: int = 0
|
|
|
|
def __post_init__(self):
|
|
super().__post_init__()
|
|
|
|
if len(self.button_data) not in [2, 4]:
|
|
raise Exception("LargeButtonScreen only supports 2 or 4 buttons")
|
|
|
|
# Maximize 2-across width; calc height with a 4:3 aspect ratio
|
|
button_width = int((self.canvas_width - (2 * GUIConstants.EDGE_PADDING) - GUIConstants.COMPONENT_PADDING) / 2)
|
|
button_height = int(button_width * (3.0 / 4.0))
|
|
|
|
# Vertically center the buttons
|
|
if len(self.button_data) == 2:
|
|
button_start_y = self.top_nav.height + int((self.canvas_height - (self.top_nav.height + GUIConstants.COMPONENT_PADDING) - button_height) / 2)
|
|
else:
|
|
button_start_y = self.top_nav.height + int((self.canvas_height - (self.top_nav.height + GUIConstants.COMPONENT_PADDING) - (2 * button_height) - GUIConstants.COMPONENT_PADDING) / 2)
|
|
|
|
self.buttons = []
|
|
for i, button_label in enumerate(self.button_data):
|
|
if type(button_label) == tuple:
|
|
(button_label, icon_name) = button_label
|
|
else:
|
|
icon_name = None
|
|
|
|
if i % 2 == 0:
|
|
button_start_x = GUIConstants.EDGE_PADDING
|
|
else:
|
|
button_start_x = GUIConstants.EDGE_PADDING + button_width + GUIConstants.COMPONENT_PADDING
|
|
|
|
button_args = {
|
|
"text": button_label,
|
|
"screen_x": button_start_x,
|
|
"screen_y": button_start_y,
|
|
"width": button_width,
|
|
"height": button_height,
|
|
"is_text_centered": True,
|
|
"font_name": self.button_font_name,
|
|
"font_size": self.button_font_size,
|
|
"selected_color": self.button_selected_color,
|
|
}
|
|
if icon_name:
|
|
button_args["icon_name"] = icon_name
|
|
button_args["text_y_offset"] = int(48 / 240 * self.renderer.canvas_height) + GUIConstants.COMPONENT_PADDING
|
|
button = LargeIconButton(**button_args)
|
|
else:
|
|
button = Button(**button_args)
|
|
|
|
self.buttons.append(button)
|
|
self.components.append(button)
|
|
|
|
if i == 1:
|
|
button_start_y += button_height + GUIConstants.COMPONENT_PADDING
|
|
|
|
self.buttons[self.selected_button].is_selected = True
|
|
|
|
|
|
def _run(self):
|
|
def swap_selected_button(new_selected_button: int):
|
|
self.buttons[self.selected_button].is_selected = False
|
|
self.buttons[self.selected_button].render()
|
|
self.selected_button = new_selected_button
|
|
self.buttons[self.selected_button].is_selected = True
|
|
self.buttons[self.selected_button].render()
|
|
|
|
while True:
|
|
ret = self._run_callback()
|
|
if ret is not None:
|
|
return ret
|
|
|
|
user_input = self.hw_inputs.wait_for(
|
|
[
|
|
HardwareButtonsConstants.KEY_UP,
|
|
HardwareButtonsConstants.KEY_DOWN,
|
|
HardwareButtonsConstants.KEY_LEFT,
|
|
HardwareButtonsConstants.KEY_RIGHT
|
|
] + HardwareButtonsConstants.KEYS__ANYCLICK,
|
|
check_release=True,
|
|
release_keys=HardwareButtonsConstants.KEYS__ANYCLICK
|
|
)
|
|
|
|
with self.renderer.lock:
|
|
if user_input == HardwareButtonsConstants.KEY_UP:
|
|
if self.selected_button in [0, 1]:
|
|
# Move selection up to top_nav
|
|
self.top_nav.is_selected = True
|
|
self.top_nav.render_buttons()
|
|
|
|
self.buttons[self.selected_button].is_selected = False
|
|
self.buttons[self.selected_button].render()
|
|
|
|
elif len(self.buttons) == 4:
|
|
swap_selected_button(self.selected_button - 2)
|
|
|
|
elif user_input == HardwareButtonsConstants.KEY_DOWN:
|
|
if self.top_nav.is_selected:
|
|
self.top_nav.is_selected = False
|
|
self.top_nav.render_buttons()
|
|
|
|
self.buttons[self.selected_button].is_selected = True
|
|
self.buttons[self.selected_button].render()
|
|
|
|
elif self.selected_button in [2, 3]:
|
|
pass
|
|
elif len(self.buttons) == 4:
|
|
swap_selected_button(self.selected_button + 2)
|
|
|
|
elif user_input == HardwareButtonsConstants.KEY_RIGHT and not self.top_nav.is_selected:
|
|
if self.selected_button in [0, 2]:
|
|
swap_selected_button(self.selected_button + 1)
|
|
|
|
elif (user_input == HardwareButtonsConstants.KEY_RIGHT and
|
|
self.top_nav.is_selected and not self.top_nav.show_power_button
|
|
):
|
|
self.top_nav.is_selected = False
|
|
self.top_nav.render_buttons()
|
|
|
|
self.buttons[self.selected_button].is_selected = True
|
|
self.buttons[self.selected_button].render()
|
|
|
|
elif user_input == HardwareButtonsConstants.KEY_LEFT and not self.top_nav.is_selected:
|
|
if self.selected_button in [1, 3]:
|
|
swap_selected_button(self.selected_button - 1)
|
|
else:
|
|
# Left from the far edge takes us up to the BACK arrow
|
|
if self.top_nav.show_back_button:
|
|
self.top_nav.is_selected = True
|
|
self.top_nav.render_buttons()
|
|
|
|
self.buttons[self.selected_button].is_selected = False
|
|
self.buttons[self.selected_button].render()
|
|
|
|
elif user_input in HardwareButtonsConstants.KEYS__ANYCLICK:
|
|
if self.top_nav.is_selected:
|
|
return self.top_nav.selected_button
|
|
return self.selected_button
|
|
|
|
# Write the screen updates
|
|
self.renderer.show_image()
|
|
|
|
|
|
|
|
@dataclass
|
|
class QRDisplayScreen(BaseScreen):
|
|
qr_encoder: EncodeQR = None
|
|
|
|
def _run(self):
|
|
from seedsigner.models.settings import Settings
|
|
settings = Settings.get_instance()
|
|
cur_brightness = settings.get_value(SettingsConstants.SETTING__QR_BRIGHTNESS)
|
|
|
|
# Loop whether the QR is a single frame or animated; each loop might adjust
|
|
# brightness setting.
|
|
while True:
|
|
ret = self._run_callback()
|
|
if ret is not None:
|
|
return ret
|
|
|
|
# convert the cur_brightness integer (31-255) into hex triplets
|
|
hex_color = (hex(cur_brightness).split('x')[1]) * 3
|
|
image = self.qr_encoder.next_part_image(240,240, border=2, background_color=hex_color)
|
|
self.renderer.show_image(image)
|
|
|
|
# Target n held frames per second before rendering next QR image
|
|
time.sleep(5/30.0)
|
|
|
|
if self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_DOWN):
|
|
# Reduce QR code background brightness
|
|
cur_brightness = max(31, cur_brightness - 31)
|
|
|
|
elif self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_UP):
|
|
# Incrase QR code background brightness
|
|
cur_brightness = min(cur_brightness + 31, 255)
|
|
|
|
elif self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_RIGHT):
|
|
break
|
|
|
|
|
|
settings.set_value(SettingsConstants.SETTING__QR_BRIGHTNESS, cur_brightness)
|
|
|
|
# TODO: handle left as BACK
|
|
|
|
|
|
|
|
@dataclass
|
|
class LargeIconStatusScreen(ButtonListScreen):
|
|
title: str = "Success!"
|
|
status_icon_name: str = SeedSignerCustomIconConstants.CIRCLE_CHECK
|
|
status_icon_size: int = GUIConstants.ICON_PRIMARY_SCREEN_SIZE
|
|
status_color: str = GUIConstants.SUCCESS_COLOR
|
|
status_headline: str = "Success!" # The colored text under the large icon
|
|
text: str = "" # The body text of the screen
|
|
button_data: list = None
|
|
allow_text_overflow: bool = False
|
|
|
|
|
|
def __post_init__(self):
|
|
self.is_bottom_list: bool = True
|
|
if not self.button_data:
|
|
self.button_data = ["OK"]
|
|
super().__post_init__()
|
|
|
|
self.status_icon = Icon(
|
|
icon_name=self.status_icon_name,
|
|
icon_size=self.status_icon_size,
|
|
icon_color=self.status_color,
|
|
)
|
|
self.status_icon.screen_y = self.top_nav.height - int(GUIConstants.COMPONENT_PADDING/2)
|
|
self.status_icon.screen_x = int((self.canvas_width - self.status_icon.width) / 2)
|
|
self.components.append(self.status_icon)
|
|
|
|
next_y = self.status_icon.screen_y + self.status_icon.height + 4
|
|
if self.status_headline:
|
|
self.warning_headline_textarea = TextArea(
|
|
text=self.status_headline,
|
|
width=self.canvas_width,
|
|
screen_y=next_y,
|
|
font_color=self.status_color,
|
|
allow_text_overflow=self.allow_text_overflow,
|
|
)
|
|
self.components.append(self.warning_headline_textarea)
|
|
next_y = next_y + self.warning_headline_textarea.height
|
|
|
|
self.components.append(TextArea(
|
|
height=self.buttons[0].screen_y - next_y,
|
|
text=self.text,
|
|
width=self.canvas_width,
|
|
screen_y=next_y,
|
|
allow_text_overflow=self.allow_text_overflow,
|
|
))
|
|
|
|
|
|
|
|
class WarningEdgesThread(BaseThread):
|
|
def __init__(self, args):
|
|
super().__init__()
|
|
self.args = args
|
|
|
|
|
|
def run(self):
|
|
screen = self.args[0]
|
|
inhale_step = 1
|
|
inhale_max = 10
|
|
inhale_hold = 8
|
|
cur_inhale_hold = 0
|
|
inhale_factor = 0
|
|
rgb = ImageColor.getrgb(screen.status_color)
|
|
|
|
def render_border(color, width):
|
|
screen.image_draw.rectangle(
|
|
(0, 0, screen.canvas_width, screen.canvas_height),
|
|
fill=None,
|
|
outline=color,
|
|
width=width,
|
|
# radius=5
|
|
)
|
|
|
|
try:
|
|
while self.keep_running:
|
|
with screen.renderer.lock:
|
|
# Ramp the edges from a darker version out to full color
|
|
inhale_scalar = inhale_factor * int(255/inhale_max)
|
|
for index, n in enumerate(range(4, -1, -1)):
|
|
# Reverse range steadily increases rgb in brightness until reaching full.
|
|
# 34 == 0x22; just eyeballed a good step size
|
|
|
|
r = max(0, rgb[0] - 34*n - inhale_scalar)
|
|
g = max(0, rgb[1] - 34*n - inhale_scalar)
|
|
b = max(0, rgb[2] - 34*n - inhale_scalar)
|
|
|
|
# `index` shrinks the border at each step
|
|
render_border((r, g, b), GUIConstants.EDGE_PADDING - 2 - index)
|
|
|
|
# Write the screen updates
|
|
screen.renderer.show_image()
|
|
|
|
if inhale_factor == inhale_max:
|
|
inhale_step = -1
|
|
elif inhale_factor == 0 and inhale_step == -1:
|
|
cur_inhale_hold += 1
|
|
if cur_inhale_hold > inhale_hold:
|
|
inhale_step = 1
|
|
cur_inhale_hold = 0
|
|
else:
|
|
# It's about to be decremented below zero
|
|
inhale_factor = 1
|
|
inhale_factor += inhale_step
|
|
|
|
# Target ~10fps
|
|
time.sleep(0.05)
|
|
|
|
except KeyboardInterrupt as e:
|
|
self.stop()
|
|
raise e
|
|
|
|
|
|
|
|
@dataclass
|
|
class WarningEdgesMixin:
|
|
status_color: str = GUIConstants.WARNING_COLOR
|
|
|
|
def __post_init__(self):
|
|
super().__post_init__()
|
|
|
|
self.threads.append(WarningEdgesThread(args=(self,)))
|
|
|
|
|
|
|
|
@dataclass
|
|
class WarningScreen(WarningEdgesMixin, LargeIconStatusScreen):
|
|
title: str = "Caution"
|
|
status_icon_name: str = SeedSignerCustomIconConstants.CIRCLE_EXCLAMATION
|
|
status_color: str = "yellow"
|
|
status_headline: str = "Privacy Leak!" # The colored text under the alert icon
|
|
|
|
def __post_init__(self):
|
|
if not self.button_data:
|
|
self.button_data = ["I Understand"]
|
|
|
|
super().__post_init__()
|
|
|
|
|
|
|
|
@dataclass
|
|
class DireWarningScreen(WarningScreen):
|
|
status_headline: str = "Classified Info!" # The colored text under the alert icon
|
|
status_color: str = GUIConstants.DIRE_WARNING_COLOR
|
|
|
|
|
|
|
|
@dataclass
|
|
class ResetScreen(BaseTopNavScreen):
|
|
def __post_init__(self):
|
|
self.title = "Restarting"
|
|
self.show_back_button = False
|
|
super().__post_init__()
|
|
|
|
self.components.append(TextArea(
|
|
text="SeedSigner is restarting.\n\nAll in-memory data will be wiped.",
|
|
screen_y=self.top_nav.height,
|
|
height=self.canvas_height - self.top_nav.height,
|
|
))
|
|
|
|
|
|
|
|
@dataclass
|
|
class PowerOffScreen(BaseTopNavScreen):
|
|
def __post_init__(self):
|
|
self.title = "Powering Off"
|
|
self.show_back_button = False
|
|
super().__post_init__()
|
|
|
|
self.components.append(TextArea(
|
|
text="Please wait about 30 seconds before disconnecting power.",
|
|
screen_y=self.top_nav.height,
|
|
height=self.canvas_height - self.top_nav.height,
|
|
))
|
|
|
|
|
|
|
|
@dataclass
|
|
class KeyboardScreen(BaseTopNavScreen):
|
|
"""
|
|
Generalized Screen for a single Keyboard layout writing user input to a
|
|
TextEntryDisplay.
|
|
|
|
Args:
|
|
* rows
|
|
* cols
|
|
* keyboard_font_name
|
|
* keyboard_font_size: Specify `None` to auto-size to Key height.
|
|
* key_height: Specify `None` to maximize key height to available space.
|
|
* keys_charset: Specify the chars displayed on the keys of the keyboard.
|
|
* keys_to_values: Optional mapping from key_charset to input value (e.g. dice icon to digit).
|
|
* return_after_n_chars: exits and returns the user's input after n characters.
|
|
* show_save_button: Render a KEY3 soft button for save & exit
|
|
* initial_value: initialize the TextEntryDisplay with an existing string
|
|
"""
|
|
rows: int = None
|
|
cols: int = None
|
|
keyboard_font_name: str = GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME
|
|
keyboard_font_size: int = GUIConstants.TOP_NAV_TITLE_FONT_SIZE + 2
|
|
key_height: int = None
|
|
keys_charset: str = None
|
|
keys_to_values: dict = None
|
|
return_after_n_chars: int = None
|
|
show_save_button: bool = False
|
|
initial_value: str = ""
|
|
|
|
def __post_init__(self):
|
|
super().__post_init__()
|
|
|
|
if self.initial_value:
|
|
self.user_input = self.initial_value
|
|
else:
|
|
self.user_input = ""
|
|
|
|
# Set up the keyboard params
|
|
if self.show_save_button:
|
|
right_panel_buttons_width = 60
|
|
hw_button_x = self.canvas_width - right_panel_buttons_width + GUIConstants.COMPONENT_PADDING
|
|
hw_button_y = int(self.canvas_height - GUIConstants.BUTTON_HEIGHT) / 2 + 60
|
|
|
|
self.keyboard_width = self.canvas_width - (GUIConstants.EDGE_PADDING + GUIConstants.COMPONENT_PADDING + right_panel_buttons_width - GUIConstants.COMPONENT_PADDING)
|
|
|
|
# Render the right button panel (only has a Key3 "Save" button)
|
|
self.save_button = IconButton(
|
|
icon_name=FontAwesomeIconConstants.SOLID_CIRCLE_CHECK,
|
|
icon_color=GUIConstants.SUCCESS_COLOR,
|
|
width=right_panel_buttons_width,
|
|
screen_x=hw_button_x,
|
|
screen_y=hw_button_y,
|
|
)
|
|
self.components.append(self.save_button)
|
|
else:
|
|
self.keyboard_width = self.canvas_width - 2*GUIConstants.EDGE_PADDING
|
|
|
|
text_entry_display_y = self.top_nav.height
|
|
text_entry_display_height = 30
|
|
|
|
keyboard_start_y = text_entry_display_y + text_entry_display_height + GUIConstants.COMPONENT_PADDING
|
|
if self.key_height is None:
|
|
self.key_height = int((self.canvas_height - GUIConstants.EDGE_PADDING - text_entry_display_y - text_entry_display_height - GUIConstants.COMPONENT_PADDING - (self.rows - 1) * 2) / self.rows)
|
|
|
|
if self.keyboard_font_size:
|
|
font_size = self.keyboard_font_size
|
|
else:
|
|
# Scale with button height
|
|
font_size = self.key_height - GUIConstants.COMPONENT_PADDING
|
|
|
|
self.keyboard = Keyboard(
|
|
draw=self.renderer.draw,
|
|
charset=self.keys_charset,
|
|
font_name=self.keyboard_font_name,
|
|
font_size=font_size,
|
|
rows=self.rows,
|
|
cols=self.cols,
|
|
rect=(
|
|
GUIConstants.EDGE_PADDING,
|
|
keyboard_start_y,
|
|
GUIConstants.EDGE_PADDING + self.keyboard_width,
|
|
keyboard_start_y + self.rows * self.key_height + (self.rows - 1) * 2
|
|
),
|
|
auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT],
|
|
render_now=False
|
|
)
|
|
self.keyboard.set_selected_key(selected_letter=self.keys_charset[0])
|
|
|
|
self.text_entry_display = TextEntryDisplay(
|
|
canvas=self.renderer.canvas,
|
|
rect=(
|
|
GUIConstants.EDGE_PADDING,
|
|
text_entry_display_y,
|
|
self.canvas_width - GUIConstants.EDGE_PADDING,
|
|
text_entry_display_y + text_entry_display_height
|
|
),
|
|
cursor_mode=TextEntryDisplay.CURSOR_MODE__BAR,
|
|
is_centered=False,
|
|
cur_text=self.initial_value,
|
|
)
|
|
|
|
|
|
def _render(self):
|
|
super()._render()
|
|
|
|
self.keyboard.render_keys()
|
|
self.text_entry_display.render()
|
|
|
|
self.renderer.show_image()
|
|
|
|
|
|
def _run(self):
|
|
self.cursor_position = len(self.user_input)
|
|
|
|
# Start the interactive update loop
|
|
while True:
|
|
input = self.hw_inputs.wait_for(
|
|
HardwareButtonsConstants.KEYS__LEFT_RIGHT_UP_DOWN + [HardwareButtonsConstants.KEY_PRESS, HardwareButtonsConstants.KEY3],
|
|
check_release=True,
|
|
release_keys=[HardwareButtonsConstants.KEY_PRESS, HardwareButtonsConstants.KEY3]
|
|
)
|
|
|
|
# Check possible exit conditions
|
|
if self.top_nav.is_selected and input == HardwareButtonsConstants.KEY_PRESS:
|
|
return RET_CODE__BACK_BUTTON
|
|
|
|
elif self.show_save_button and input == HardwareButtonsConstants.KEY3:
|
|
# Save!
|
|
# First show the save button reacting to the click
|
|
self.save_button.is_selected = True
|
|
self.save_button.render()
|
|
self.renderer.show_image()
|
|
|
|
# Then return the input to the View
|
|
if len(self.user_input) > 0:
|
|
return self.user_input.strip()
|
|
|
|
# Process normal input
|
|
if input in [HardwareButtonsConstants.KEY_UP, HardwareButtonsConstants.KEY_DOWN] and self.top_nav.is_selected:
|
|
# We're navigating off the previous button
|
|
self.top_nav.is_selected = False
|
|
self.top_nav.render_buttons()
|
|
|
|
# Override the actual input w/an ENTER signal for the Keyboard
|
|
if input == HardwareButtonsConstants.KEY_DOWN:
|
|
input = Keyboard.ENTER_TOP
|
|
else:
|
|
input = Keyboard.ENTER_BOTTOM
|
|
elif input in [HardwareButtonsConstants.KEY_LEFT, HardwareButtonsConstants.KEY_RIGHT] and self.top_nav.is_selected:
|
|
# ignore
|
|
continue
|
|
|
|
ret_val = self.keyboard.update_from_input(input)
|
|
|
|
# Now process the result from the keyboard
|
|
if ret_val in Keyboard.EXIT_DIRECTIONS:
|
|
self.top_nav.is_selected = True
|
|
self.top_nav.render_buttons()
|
|
|
|
elif ret_val in Keyboard.ADDITIONAL_KEYS and input == HardwareButtonsConstants.KEY_PRESS:
|
|
if ret_val == Keyboard.KEY_BACKSPACE["code"]:
|
|
if len(self.user_input) > 0:
|
|
self.user_input = self.user_input[:-1]
|
|
self.cursor_position -= 1
|
|
|
|
elif input == HardwareButtonsConstants.KEY_PRESS and ret_val not in Keyboard.ADDITIONAL_KEYS:
|
|
# User has locked in the current letter
|
|
if self.keys_to_values:
|
|
# Map the Key display char to its output value (e.g. dice icon to digit)
|
|
ret_val = self.keys_to_values[ret_val]
|
|
self.user_input += ret_val
|
|
self.cursor_position += 1
|
|
|
|
if self.cursor_position == self.return_after_n_chars:
|
|
return self.user_input
|
|
|
|
# Render a new TextArea over the TopNav title bar
|
|
if self.update_title():
|
|
TextArea(
|
|
text=self.title,
|
|
font_name=GUIConstants.TOP_NAV_TITLE_FONT_NAME,
|
|
font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE,
|
|
height=self.top_nav.height,
|
|
).render()
|
|
self.top_nav.render_buttons()
|
|
|
|
elif input in HardwareButtonsConstants.KEYS__LEFT_RIGHT_UP_DOWN:
|
|
# Live joystick movement; haven't locked this new letter in yet.
|
|
# Leave current spot blank for now. Only update the active keyboard keys
|
|
# when a selection has been locked in (KEY_PRESS) or removed ("del").
|
|
pass
|
|
|
|
# Render the text entry display and cursor block
|
|
self.text_entry_display.render(self.user_input)
|
|
|
|
self.renderer.show_image()
|
|
|
|
|
|
def update_title(self) -> bool:
|
|
"""
|
|
Optionally update the self.title after each completed key input.
|
|
|
|
e.g. to increment the dice roll count:
|
|
self.title = f"Roll {self.cursor_position + 1}"
|
|
"""
|
|
return False
|