You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
trezor-firmware/core/src/trezor/ui/components/tt/passphrase.py

263 lines
7.8 KiB

from micropython import const
from typing import TYPE_CHECKING
from trezor import io, loop, res, ui, workflow
from trezor.ui import display
from .button import Button, ButtonClear, ButtonConfirm
from .swipe import SWIPE_HORIZONTAL, SWIPE_LEFT, Swipe
if TYPE_CHECKING:
from typing import Iterable
from .button import ButtonContent, ButtonStyleStateType
SPACE = res.load(ui.ICON_SPACE)
KEYBOARD_KEYS = (
("1", "2", "3", "4", "5", "6", "7", "8", "9", "0"),
(SPACE, "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz", "*#"),
(SPACE, "ABC", "DEF", "GHI", "JKL", "MNO", "PQRS", "TUV", "WXYZ", "*#"),
("_<>", ".:@", "/|\\", "!()", "+%&", "-[]", "?{}", ",'`", ';"~', "$^="),
)
def digit_area(i: int) -> ui.Area:
if i == 9: # 0-position
i = 10 # display it in the middle
return ui.grid(i + 3) # skip the first line
def render_scrollbar(page: int) -> None:
BBOX = const(240)
SIZE = const(8)
pages = len(KEYBOARD_KEYS)
padding = 12
if pages * padding > BBOX:
padding = BBOX // pages
x = (BBOX // 2) - (pages // 2) * padding
Y = const(44)
for i in range(0, pages):
if i == page:
fg = ui.FG
else:
fg = ui.DARK_GREY
ui.display.bar_radius(x + i * padding, Y, SIZE, SIZE, fg, ui.BG, SIZE // 2)
class KeyButton(Button):
def __init__(
self, area: ui.Area, content: ButtonContent, keyboard: "PassphraseKeyboard"
) -> None:
self.keyboard = keyboard
super().__init__(area, content)
def on_click(self) -> None:
self.keyboard.on_key_click(self)
def get_text_content(self) -> str:
if self.text:
return self.text
elif self.icon is SPACE:
return " "
else:
raise TypeError
def key_buttons(
keys: Iterable[ButtonContent], keyboard: "PassphraseKeyboard"
) -> list[KeyButton]:
return [KeyButton(digit_area(i), k, keyboard) for i, k in enumerate(keys)]
class Input(Button):
def __init__(self, area: ui.Area, text: str) -> None:
super().__init__(area, text)
self.pending = False
self.disable()
def edit(self, text: str, pending: bool) -> None:
self.text = text
self.pending = pending
self.repaint = True
def render_content(
self, s: ButtonStyleStateType, ax: int, ay: int, aw: int, ah: int
) -> None:
text_style = s.text_style
fg_color = s.fg_color
bg_color = s.bg_color
p = self.pending # should we draw the pending marker?
t = self.text # input content
tx = ax + 24 # x-offset of the content
ty = ay + ah // 2 + 8 # y-offset of the content
maxlen = const(14) # maximum text length
# input content
if len(t) > maxlen:
t = "<" + t[-maxlen:] # too long, align to the right
width = display.text_width(t, text_style)
display.text(tx, ty, t, text_style, fg_color, bg_color)
if p: # pending marker
pw = display.text_width(t[-1:], text_style)
px = tx + width - pw
display.bar(px, ty + 2, pw + 1, 3, fg_color)
else: # cursor
cx = tx + width + 1
display.bar(cx, ty - 18, 2, 22, fg_color)
def on_click(self) -> None:
pass
class Prompt(ui.Component):
def __init__(self, text: str) -> None:
super().__init__()
self.text = text
def on_render(self) -> None:
if self.repaint:
display.bar(0, 0, ui.WIDTH, 48, ui.BG)
display.text_center(ui.WIDTH // 2, 32, self.text, ui.BOLD, ui.GREY, ui.BG)
self.repaint = False
CANCELLED = object()
class PassphraseKeyboard(ui.Layout):
def __init__(self, prompt: str, max_length: int, page: int = 1) -> None:
super().__init__()
self.prompt = Prompt(prompt)
self.max_length = max_length
self.page = page
self.input = Input(ui.grid(0, n_x=1, n_y=6), "")
self.back = Button(ui.grid(12), res.load(ui.ICON_BACK), ButtonClear)
self.back.on_click = self.on_back_click
self.back.disable()
self.done = Button(ui.grid(14), res.load(ui.ICON_CONFIRM), ButtonConfirm)
self.done.on_click = self.on_confirm
self.keys = key_buttons(KEYBOARD_KEYS[self.page], self)
self.pending_button: KeyButton | None = None
self.pending_index = 0
def dispatch(self, event: int, x: int, y: int) -> None:
if self.input.text:
self.input.dispatch(event, x, y)
else:
self.prompt.dispatch(event, x, y)
self.back.dispatch(event, x, y)
self.done.dispatch(event, x, y)
for btn in self.keys:
btn.dispatch(event, x, y)
if event == ui.RENDER:
render_scrollbar(self.page)
def on_back_click(self) -> None:
# Backspace was clicked. If we have any content in the input, let's delete
# the last character. Otherwise cancel.
text = self.input.text
if text:
self.edit(text[:-1])
else:
self.on_cancel()
def on_key_click(self, button: KeyButton) -> None:
# Key button was clicked. If this button is pending, let's cycle the
# pending character in input. If not, let's just append the first
# character.
button_text = button.get_text_content()
if self.pending_button is button:
index = (self.pending_index + 1) % len(button_text)
prefix = self.input.text[:-1]
else:
index = 0
prefix = self.input.text
if len(button_text) > 1:
self.edit(prefix + button_text[index], button, index)
else:
self.edit(prefix + button_text[index])
def on_timeout(self) -> None:
# Timeout occurred, let's just reset the pending marker.
self.edit(self.input.text)
def edit(self, text: str, button: KeyButton | None = None, index: int = 0) -> None:
if len(text) > self.max_length:
return
self.pending_button = button
self.pending_index = index
# modify the input state
pending = button is not None
self.input.edit(text, pending)
if text:
self.back.enable()
else:
self.back.disable()
self.prompt.repaint = True
async def handle_input(self) -> None:
touch = loop.wait(io.TOUCH)
timeout = loop.sleep(1000)
race_touch = loop.race(touch)
race_timeout = loop.race(touch, timeout)
while True:
if self.pending_button is not None:
race = race_timeout
else:
race = race_touch
result = await race
if touch in race.finished:
event, x, y = result
workflow.idle_timer.touch()
self.dispatch(event, x, y)
else:
self.on_timeout()
async def handle_paging(self) -> None:
swipe = await Swipe(SWIPE_HORIZONTAL)
if swipe == SWIPE_LEFT:
self.page = (self.page + 1) % len(KEYBOARD_KEYS)
else:
self.page = (self.page - 1) % len(KEYBOARD_KEYS)
self.keys = key_buttons(KEYBOARD_KEYS[self.page], self)
self.back.repaint = True
self.done.repaint = True
self.input.repaint = True
self.prompt.repaint = True
def on_cancel(self) -> None:
raise ui.Result(CANCELLED)
def on_confirm(self) -> None:
raise ui.Result(self.input.text)
def create_tasks(self) -> tuple[loop.AwaitableTask, ...]:
tasks: tuple[loop.Task, ...] = (
self.handle_input(),
self.handle_rendering(),
self.handle_paging(),
)
if __debug__:
from apps.debug import input_signal
return tasks + (input_signal(),)
else:
return tasks