from micropython import const from typing import TYPE_CHECKING from trezor import loop, res, ui, utils, wire, workflow from trezor.enums import ButtonRequestType from trezor.messages import ButtonAck, ButtonRequest from ..common.confirm import CANCELLED, CONFIRMED, GO_BACK, SHOW_PAGINATED from .button import Button, ButtonCancel, ButtonConfirm, ButtonDefault from .confirm import Confirm from .swipe import SWIPE_DOWN, SWIPE_UP, SWIPE_VERTICAL, Swipe from .text import ( LINE_WIDTH_PAGINATED, TEXT_MAX_LINES, TEXT_MAX_LINES_NO_HEADER, Span, Text, ) if TYPE_CHECKING: from typing import Any, Callable, Iterable from ..common.text import TextContent WAS_PAGED = object() def render_scrollbar(pages: int, page: int) -> None: BBOX = const(220) SIZE = const(8) padding = 14 if pages * padding > BBOX: padding = BBOX // pages X = const(220) Y = (BBOX // 2) - (pages // 2) * padding for i in range(0, pages): if i == page: fg = ui.FG else: fg = ui.GREY ui.display.bar_radius(X, Y + i * padding, SIZE, SIZE, fg, ui.BG, 4) def render_swipe_icon(x_offset: int = 0) -> None: if utils.DISABLE_ANIMATION: c = ui.GREY else: PULSE_PERIOD = const(1_200_000) t = ui.pulse(PULSE_PERIOD) c = ui.blend(ui.GREY, ui.DARK_GREY, t) icon = res.load(ui.ICON_SWIPE) ui.display.icon(70 + x_offset, 205, icon, c, ui.BG) def render_swipe_text(x_offset: int = 0) -> None: ui.display.text_center(130 + x_offset, 220, "Swipe", ui.BOLD, ui.GREY, ui.BG) class Paginated(ui.Layout): def __init__( self, pages: list[ui.Component], page: int = 0, back_button: bool = False ): super().__init__() self.pages = pages self.page = page self.back_button = None if back_button: area = ui.grid(16, n_x=4) icon = res.load(ui.ICON_BACK) self.back_button = Button(area, icon, ButtonDefault) self.back_button.on_click = self.on_back_click def dispatch(self, event: int, x: int, y: int) -> None: pages = self.pages page = self.page length = len(pages) last_page = page >= length - 1 x_offset = 0 pages[page].dispatch(event, x, y) if self.back_button is not None and not last_page: self.back_button.dispatch(event, x, y) x_offset = 30 if event is ui.REPAINT: self.repaint = True elif event is ui.RENDER: if not last_page: render_swipe_icon(x_offset=x_offset) if self.repaint: render_swipe_text(x_offset=x_offset) if self.repaint: render_scrollbar(length, page) self.repaint = False async def handle_paging(self) -> None: if self.page == 0: directions = SWIPE_UP elif self.page == len(self.pages) - 1: directions = SWIPE_DOWN else: directions = SWIPE_VERTICAL if __debug__: from apps.debug import swipe_signal swipe = await loop.race(Swipe(directions), swipe_signal()) else: swipe = await Swipe(directions) if swipe is SWIPE_UP: self.page = min(self.page + 1, len(self.pages) - 1) elif swipe is SWIPE_DOWN: self.page = max(self.page - 1, 0) self.on_change() raise ui.Result(WAS_PAGED) async def interact( self, ctx: wire.GenericContext, code: ButtonRequestType = ButtonRequestType.Other, ) -> Any: workflow.close_others() await ctx.call(ButtonRequest(code=code, pages=len(self.pages)), ButtonAck) result = WAS_PAGED while result is WAS_PAGED: result = await ctx.wait(self) return result def create_tasks(self) -> tuple[loop.AwaitableTask, ...]: tasks: tuple[loop.AwaitableTask, ...] = ( self.handle_input(), self.handle_rendering(), self.handle_paging(), ) if __debug__: # XXX This isn't strictly correct, as it allows *any* Paginated layout to be # shut down by a DebugLink confirm, even if used outside of a confirm() call # But we don't have any such usages in the codebase, and it doesn't actually # make much sense to use a Paginated without a way to confirm it. from apps.debug import confirm_signal return tasks + (confirm_signal(),) else: return tasks def on_change(self) -> None: pass def on_back_click(self) -> None: raise ui.Result(GO_BACK) if __debug__: def read_content(self) -> list[str]: return self.pages[self.page].read_content() class AskPaginated(ui.Component): def __init__(self, content: ui.Component, button_text: str = "Show all") -> None: super().__init__() self.content = content self.button = Button(ui.grid(3, n_x=1), button_text, ButtonDefault) self.button.on_click = self.on_show_paginated_click def dispatch(self, event: int, x: int, y: int) -> None: self.content.dispatch(event, x, y) self.button.dispatch(event, x, y) def on_show_paginated_click(self) -> None: raise ui.Result(SHOW_PAGINATED) if __debug__: def read_content(self) -> list[str]: return self.content.read_content() class PageWithButtons(ui.Component): def __init__( self, content: ui.Component, paginated: "PaginatedWithButtons", index: int, count: int, ) -> None: super().__init__() self.content = content self.paginated = paginated self.index = index self.count = count # somewhere in the middle, we can go up or down left = res.load(ui.ICON_BACK) left_style = ButtonDefault right = res.load(ui.ICON_CLICK) right_style = ButtonDefault if self.index == 0: # first page, we can cancel or go down left = res.load(ui.ICON_CANCEL) left_style = ButtonCancel right = res.load(ui.ICON_CLICK) right_style = ButtonDefault elif self.index == count - 1: # last page, we can go up or confirm left = res.load(ui.ICON_BACK) left_style = ButtonDefault right = res.load(ui.ICON_CONFIRM) right_style = ButtonConfirm self.left = Button(ui.grid(8, n_x=2), left, left_style) self.left.on_click = self.on_left self.right = Button(ui.grid(9, n_x=2), right, right_style) self.right.on_click = self.on_right def dispatch(self, event: int, x: int, y: int) -> None: self.content.dispatch(event, x, y) self.left.dispatch(event, x, y) self.right.dispatch(event, x, y) def on_left(self) -> None: if self.index == 0: self.paginated.on_cancel() else: self.paginated.on_up() def on_right(self) -> None: if self.index == self.count - 1: self.paginated.on_confirm() else: self.paginated.on_down() if __debug__: def read_content(self) -> list[str]: return self.content.read_content() class PaginatedWithButtons(ui.Layout): def __init__( self, pages: list[ui.Component], page: int = 0, one_by_one: bool = False ) -> None: super().__init__() self.pages = [ PageWithButtons(p, self, i, len(pages)) for i, p in enumerate(pages) ] self.page = page self.one_by_one = one_by_one def dispatch(self, event: int, x: int, y: int) -> None: pages = self.pages page = self.page pages[page].dispatch(event, x, y) if event is ui.RENDER: render_scrollbar(len(pages), page) def on_up(self) -> None: self.page = max(self.page - 1, 0) self.pages[self.page].dispatch(ui.REPAINT, 0, 0) self.on_change() def on_down(self) -> None: self.page = min(self.page + 1, len(self.pages) - 1) self.pages[self.page].dispatch(ui.REPAINT, 0, 0) self.on_change() def on_confirm(self) -> None: raise ui.Result(CONFIRMED) def on_cancel(self) -> None: raise ui.Result(CANCELLED) def on_change(self) -> None: if self.one_by_one: raise ui.Result(self.page) if __debug__: def read_content(self) -> list[str]: return self.pages[self.page].read_content() def create_tasks(self) -> tuple[loop.AwaitableTask, ...]: from apps.debug import confirm_signal return super().create_tasks() + (confirm_signal(),) def paginate_text( text: str, header: str, font: int = ui.NORMAL, header_icon: str = ui.ICON_DEFAULT, icon_color: int = ui.ORANGE_ICON, break_words: bool = False, confirm: Callable[[ui.Component], ui.Layout] = Confirm, ) -> ui.Layout: span = Span(text, 0, font, break_words=break_words) if span.count_lines() <= TEXT_MAX_LINES: result = Text( header, header_icon=header_icon, icon_color=icon_color, new_lines=False, break_words=break_words, ) result.content = [font, text] return confirm(result) else: pages: list[ui.Component] = [] span.reset( text, 0, font, break_words=break_words, line_width=LINE_WIDTH_PAGINATED ) while span.has_more_content(): # advance to first line of the page span.next_line() page = Text( header, header_icon=header_icon, icon_color=icon_color, new_lines=False, content_offset=0, char_offset=span.start, line_width=LINE_WIDTH_PAGINATED, break_words=break_words, render_page_overflow=False, ) page.content = [font, text] pages.append(page) # roll over the remaining lines on the page for _ in range(TEXT_MAX_LINES - 1): span.next_line() pages[-1] = confirm(pages[-1]) return Paginated(pages) PAGEBREAK = 0, "" def paginate_paragraphs( para: Iterable[tuple[int, str]], header: str | None, header_icon: str = ui.ICON_DEFAULT, icon_color: int = ui.ORANGE_ICON, break_words: bool = False, confirm: Callable[[ui.Component], ui.Layout] = Confirm, back_button: bool = False, ) -> ui.Layout: span = Span("", 0, ui.NORMAL, break_words=break_words) lines = 0 content: list[TextContent] = [] max_lines = TEXT_MAX_LINES_NO_HEADER if header is None else TEXT_MAX_LINES for item in para: if item is PAGEBREAK: continue span.reset(item[1], 0, item[0], break_words=break_words) lines += span.count_lines() # we'll need this for multipage too if content: content.append("\n") content.extend(item) if lines <= max_lines: result = Text( header, header_icon=header_icon, icon_color=icon_color, new_lines=False, break_words=break_words, ) result.content = content return confirm(result) else: pages: list[ui.Component] = [] lines_left = 0 content_ctr = 0 page: Text | None = None for item in para: if item is PAGEBREAK: if page is not None: page.max_lines -= lines_left lines_left = 0 continue span.reset( item[1], 0, item[0], break_words=break_words, line_width=LINE_WIDTH_PAGINATED, ) while span.has_more_content(): span.next_line() if lines_left <= 0: page = Text( header, header_icon=header_icon, icon_color=icon_color, new_lines=False, content_offset=content_ctr * 3 + 1, # font, _text_, newline char_offset=span.start, line_width=LINE_WIDTH_PAGINATED, render_page_overflow=False, break_words=break_words, ) page.content = content pages.append(page) lines_left = max_lines - 1 else: lines_left -= 1 content_ctr += 1 pages[-1] = confirm(pages[-1]) return Paginated(pages, back_button=back_button)