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.
537 lines
17 KiB
537 lines
17 KiB
from micropython import const
|
|
from typing import TYPE_CHECKING
|
|
|
|
from trezor import ui
|
|
|
|
from ...constants import (
|
|
PAGINATION_MARGIN_RIGHT,
|
|
TEXT_HEADER_HEIGHT,
|
|
TEXT_LINE_HEIGHT,
|
|
TEXT_LINE_HEIGHT_HALF,
|
|
TEXT_MARGIN_LEFT,
|
|
TEXT_MAX_LINES,
|
|
TEXT_MAX_LINES_NO_HEADER,
|
|
)
|
|
|
|
LINE_WIDTH = ui.WIDTH - TEXT_MARGIN_LEFT
|
|
LINE_WIDTH_PAGINATED = LINE_WIDTH - PAGINATION_MARGIN_RIGHT
|
|
|
|
if TYPE_CHECKING:
|
|
from typing import Any, Sequence, Union
|
|
|
|
TextContent = Union[str, int]
|
|
|
|
# needs to be different from all colors and font ids
|
|
BR = const(-256)
|
|
BR_HALF = const(-257)
|
|
|
|
_FONTS = (ui.NORMAL, ui.BOLD, ui.MONO)
|
|
|
|
DASH_WIDTH = ui.display.text_width("-", ui.BOLD)
|
|
|
|
|
|
class Span:
|
|
def __init__(
|
|
self,
|
|
string: str = "",
|
|
start: int = 0,
|
|
font: int = ui.NORMAL,
|
|
line_width: int = LINE_WIDTH,
|
|
offset_x: int = 0,
|
|
break_words: bool = False,
|
|
) -> None:
|
|
self.reset(string, start, font, line_width, offset_x, break_words)
|
|
|
|
def reset(
|
|
self,
|
|
string: str,
|
|
start: int,
|
|
font: int,
|
|
line_width: int = LINE_WIDTH,
|
|
offset_x: int = 0,
|
|
break_words: bool = False,
|
|
) -> None:
|
|
self.string = string
|
|
self.start = start
|
|
self.font = font
|
|
self.line_width = line_width
|
|
self.offset_x = offset_x
|
|
self.break_words = break_words
|
|
|
|
self.length = 0
|
|
self.width = 0
|
|
self.word_break = False
|
|
self.advance_whitespace = False
|
|
|
|
def count_lines(self) -> int:
|
|
"""Get a number of lines in the specified string.
|
|
|
|
Should be used with a cleanly reset span. Leaves the span in the final position.
|
|
"""
|
|
n_lines = 0
|
|
while self.next_line():
|
|
n_lines += 1
|
|
# deal with trailing newlines: if the final span does not have any content,
|
|
# do not count it
|
|
if self.length > 0:
|
|
n_lines += 1
|
|
return n_lines
|
|
|
|
def has_more_content(self) -> bool:
|
|
"""Look ahead to check if there is more content after the current span is
|
|
consumed.
|
|
"""
|
|
start = self.start + self.length
|
|
if self.advance_whitespace:
|
|
start += 1
|
|
return start < len(self.string)
|
|
|
|
def next_line(self) -> bool:
|
|
"""Advance the span to point to contents of the next line.
|
|
|
|
Returns True if the renderer should make newline afterwards, False if this is
|
|
the end of the text.
|
|
|
|
Within the renderer, we use this as:
|
|
|
|
>>> while span.next_line():
|
|
>>> render_the_line(span)
|
|
>>> go_to_next_line()
|
|
>>> render_the_line(span) # final line without linebreak
|
|
|
|
This is unsuitable for other uses however. To count lines (as in
|
|
`apps.common.layout.paginate_text`), use instead:
|
|
|
|
>>> while span.has_more_content():
|
|
>>> span.next_line()
|
|
"""
|
|
# We are making copies of most class variables so that the lookup is faster.
|
|
# This also allows us to pick defaults independently of the current status
|
|
string = self.string
|
|
start = self.start + self.length
|
|
line_width = self.line_width - self.offset_x
|
|
break_words = self.break_words
|
|
font = self.font
|
|
|
|
self.offset_x = 0
|
|
width = 0
|
|
result_width = 0
|
|
length = 0
|
|
|
|
if start >= len(string):
|
|
return False
|
|
|
|
# advance over the left-over whitespace character from last time
|
|
if self.advance_whitespace:
|
|
start += 1
|
|
|
|
word_break = True
|
|
advance_whitespace = False
|
|
for i in range(len(string) - start):
|
|
nextchar_width = ui.display.text_width(string[start + i], font)
|
|
|
|
if string[start + i] in " \n":
|
|
word_break = False
|
|
length = i # break is _before_ the whitespace
|
|
advance_whitespace = True
|
|
result_width = width
|
|
if string[start + i] == "\n":
|
|
# do not continue over newline
|
|
break
|
|
|
|
elif width + nextchar_width > line_width:
|
|
# this char would overflow the line. end loop, use last result
|
|
break
|
|
|
|
elif (
|
|
break_words or word_break
|
|
) and width + nextchar_width + DASH_WIDTH <= line_width:
|
|
# Trying a possible break in the middle of a word.
|
|
# We can do this if:
|
|
# - we haven't found a space yet (word_break is still True) -- if a word
|
|
# doesn't fit on a single line, this will place a break in it
|
|
# - we are allowed to break words (break_words is True)
|
|
# AND the current character and a word-break dash will fit on the line.
|
|
result_width = width + nextchar_width
|
|
length = i + 1 # break is _after_ current character
|
|
advance_whitespace = False
|
|
word_break = True
|
|
|
|
width += nextchar_width
|
|
|
|
else:
|
|
# whole string (from offset) fits
|
|
word_break = False
|
|
advance_whitespace = False
|
|
result_width = width
|
|
length = len(string) - start
|
|
|
|
self.start = start
|
|
self.length = length
|
|
self.width = result_width
|
|
self.word_break = word_break
|
|
self.advance_whitespace = advance_whitespace
|
|
return start + length < len(string)
|
|
|
|
|
|
_WORKING_SPAN = Span()
|
|
|
|
|
|
def render_text(
|
|
items: Sequence[TextContent],
|
|
new_lines: bool,
|
|
max_lines: int,
|
|
font: int = ui.NORMAL,
|
|
fg: int = ui.FG,
|
|
bg: int = ui.BG,
|
|
offset_x: int = TEXT_MARGIN_LEFT,
|
|
offset_y: int = TEXT_HEADER_HEIGHT + TEXT_LINE_HEIGHT,
|
|
line_width: int = ui.WIDTH - TEXT_MARGIN_LEFT,
|
|
item_offset: int = 0,
|
|
char_offset: int = 0,
|
|
break_words: bool = False,
|
|
render_page_overflow: bool = True,
|
|
) -> None:
|
|
"""Render a sequence of items on screen.
|
|
|
|
The items can either be strings, or rendering instructions specified as ints.
|
|
They can change font, insert an explicit linebreak, or change color of the following
|
|
text.
|
|
|
|
If `new_lines` is true, a linebreak is rendered after every string. In effect, the
|
|
following calls are equivalent:
|
|
|
|
>>> render_text(["hello", "world"], new_lines=True)
|
|
>>> render_text(["hello\nworld"], new_lines=False)
|
|
|
|
TODO, we should get rid of all cases that use `new_lines=True`
|
|
|
|
If the rendered text ends up longer than `max_lines`, a trailing "..." is rendered
|
|
at end. This indicates to the user that the full contents have not been shown.
|
|
It is possible to override this behavior via `render_page_overflow` argument --
|
|
if false, the trailing "..." is not shown. This is useful when the rendered text is
|
|
in fact paginated.
|
|
|
|
`font` specifies the default font, but that can be overridden by font instructions
|
|
in `items`.
|
|
`fg` specifies default foreground color, which can also be overridden by instructions
|
|
in `items`.
|
|
`bg` specifies background color. This cannot be overridden.
|
|
|
|
`offset_x` and `offset_y` specify starting XY position of the text bounding box.
|
|
`line_width` specifies width of the bounding box. Height of the bounding box is
|
|
calculated as `max_lines * TEXT_LINE_HEIGHT`.
|
|
|
|
`item_offset` and `char_offset` must be specified together. Item offset specifies
|
|
the first element of `items` which should be considered, and char offset specifies
|
|
the first character of the indicated item which should be considered.
|
|
The purpose is to allow rendering different "pages" of text, using the same `items`
|
|
argument (slicing the list could be expensive in terms of memory).
|
|
|
|
If `break_words` is false (default), linebreaks will only be rendered (a) at
|
|
whitespace, or (b) in case a word does not fit on a single line. If true, whitespace
|
|
is ignored and linebreaks are inserted after the last character that fits.
|
|
"""
|
|
# initial rendering state
|
|
INITIAL_OFFSET_X = offset_x
|
|
offset_y_max = offset_y + (TEXT_LINE_HEIGHT * (max_lines - 1))
|
|
span = _WORKING_SPAN
|
|
|
|
# scan through up to item_offset so that the current font & color is up to date
|
|
for item_index in range(item_offset):
|
|
item = items[item_index]
|
|
if isinstance(item, int):
|
|
if item is BR or item is BR_HALF:
|
|
# do nothing
|
|
pass
|
|
elif item in _FONTS:
|
|
font = item
|
|
else:
|
|
fg = item
|
|
|
|
for item_index in range(item_offset, len(items)):
|
|
# load current item
|
|
item = items[item_index]
|
|
|
|
if isinstance(item, int):
|
|
if item is BR or item is BR_HALF:
|
|
# line break or half-line break
|
|
if offset_y > offset_y_max:
|
|
if render_page_overflow:
|
|
ui.display.text(offset_x, offset_y, "...", ui.BOLD, ui.GREY, bg)
|
|
return
|
|
offset_x = INITIAL_OFFSET_X
|
|
offset_y += TEXT_LINE_HEIGHT if item is BR else TEXT_LINE_HEIGHT_HALF
|
|
elif item in _FONTS:
|
|
# change of font style
|
|
font = item
|
|
else:
|
|
# change of foreground color
|
|
fg = item
|
|
continue
|
|
|
|
# XXX hack:
|
|
# if the upcoming word does not fit on this line but fits on the following,
|
|
# render it after a linebreak
|
|
item_width = ui.display.text_width(item, font)
|
|
if (
|
|
item_width <= line_width # pylint: disable=chained-comparison
|
|
and item_width + offset_x - INITIAL_OFFSET_X > line_width
|
|
and "\n" not in item
|
|
):
|
|
offset_y += TEXT_LINE_HEIGHT
|
|
ui.display.text(INITIAL_OFFSET_X, offset_y, item, font, fg, bg)
|
|
offset_x = INITIAL_OFFSET_X + item_width
|
|
continue
|
|
|
|
span.reset(
|
|
item,
|
|
char_offset,
|
|
font,
|
|
line_width=line_width,
|
|
offset_x=offset_x - INITIAL_OFFSET_X,
|
|
break_words=break_words,
|
|
)
|
|
char_offset = 0
|
|
while span.next_line():
|
|
ui.display.text(
|
|
offset_x, offset_y, item, font, fg, bg, span.start, span.length
|
|
)
|
|
end_of_page = offset_y >= offset_y_max
|
|
have_more_content = span.has_more_content() or item_index < len(items) - 1
|
|
|
|
if end_of_page and have_more_content and render_page_overflow:
|
|
ui.display.text(
|
|
offset_x + span.width, offset_y, "...", ui.BOLD, ui.GREY, bg
|
|
)
|
|
elif span.word_break:
|
|
ui.display.text(
|
|
offset_x + span.width, offset_y, "-", ui.BOLD, ui.GREY, bg
|
|
)
|
|
|
|
if end_of_page:
|
|
return
|
|
|
|
offset_x = INITIAL_OFFSET_X
|
|
offset_y += TEXT_LINE_HEIGHT
|
|
|
|
# render last chunk
|
|
ui.display.text(offset_x, offset_y, item, font, fg, bg, span.start, span.length)
|
|
|
|
if new_lines:
|
|
offset_x = INITIAL_OFFSET_X
|
|
offset_y += TEXT_LINE_HEIGHT
|
|
elif span.width > 0:
|
|
# only advance cursor if we actually rendered anything
|
|
offset_x += span.width
|
|
|
|
|
|
if __debug__:
|
|
|
|
class DisplayMock:
|
|
"""Mock Display class that stores rendered text in an array.
|
|
|
|
Used to extract data for unit tests.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.screen_contents: list[str] = []
|
|
self.orig_display = ui.display
|
|
|
|
def __getattr__(self, key: str) -> Any:
|
|
return getattr(self.orig_display, key)
|
|
|
|
def __enter__(self) -> None:
|
|
ui.display = self
|
|
|
|
def __exit__(self, exc: Any, exc_type: Any, tb: Any) -> None:
|
|
ui.display = self.orig_display
|
|
|
|
def text(
|
|
self,
|
|
offset_x: int,
|
|
offset_y: int,
|
|
string: str,
|
|
font: int,
|
|
fg: int,
|
|
bg: int,
|
|
start: int = 0,
|
|
length: int | None = None,
|
|
) -> None:
|
|
if length is None:
|
|
length = len(string) - start
|
|
self.screen_contents.append(string[start : start + length])
|
|
|
|
|
|
class TextBase(ui.Component):
|
|
def __init__(
|
|
self,
|
|
header_text: str | None,
|
|
header_icon: str = ui.ICON_DEFAULT,
|
|
icon_color: int = ui.ORANGE_ICON,
|
|
max_lines: int | None = None,
|
|
new_lines: bool = True,
|
|
break_words: bool = False,
|
|
render_page_overflow: bool = True,
|
|
content_offset: int = 0,
|
|
char_offset: int = 0,
|
|
line_width: int = ui.WIDTH - TEXT_MARGIN_LEFT,
|
|
):
|
|
super().__init__()
|
|
self.header_text = header_text
|
|
self.header_icon = header_icon
|
|
self.icon_color = icon_color
|
|
|
|
if max_lines is None:
|
|
self.max_lines = (
|
|
TEXT_MAX_LINES_NO_HEADER if self.header_text is None else TEXT_MAX_LINES
|
|
)
|
|
else:
|
|
self.max_lines = max_lines
|
|
|
|
self.new_lines = new_lines
|
|
self.break_words = break_words
|
|
self.render_page_overflow = render_page_overflow
|
|
self.content: list[TextContent] = []
|
|
self.content_offset = content_offset
|
|
self.char_offset = char_offset
|
|
self.line_width = line_width
|
|
|
|
def normal(self, *content: TextContent) -> None:
|
|
self.content.append(ui.NORMAL)
|
|
self.content.extend(content)
|
|
|
|
def bold(self, *content: TextContent) -> None:
|
|
self.content.append(ui.BOLD)
|
|
self.content.extend(content)
|
|
|
|
def mono(self, *content: TextContent) -> None:
|
|
self.content.append(ui.MONO)
|
|
self.content.extend(content)
|
|
|
|
def br(self) -> None:
|
|
self.content.append(BR)
|
|
|
|
def br_half(self) -> None:
|
|
self.content.append(BR_HALF)
|
|
|
|
def format_parametrized(
|
|
self,
|
|
format_string: str,
|
|
*params: str,
|
|
font: int = ui.NORMAL,
|
|
param_font: int = ui.BOLD,
|
|
) -> None:
|
|
parts = format_string.split("{}", len(params))
|
|
for i in range(len(parts)): # pylint: disable=consider-using-enumerate
|
|
self.content.append(font)
|
|
self.content.append(parts[i])
|
|
if i < len(parts) - 1 and i < len(params):
|
|
param = params[i]
|
|
self.content.append(param_font)
|
|
self.content.append(param)
|
|
|
|
def on_render(self) -> None:
|
|
pass
|
|
|
|
if __debug__:
|
|
|
|
def read_content(self) -> list[str]:
|
|
display_mock = DisplayMock()
|
|
should_repaint = self.repaint
|
|
try:
|
|
with display_mock:
|
|
self.repaint = True
|
|
self.on_render()
|
|
finally:
|
|
self.repaint = should_repaint
|
|
return [self.header_text or ""] + display_mock.screen_contents
|
|
|
|
|
|
LABEL_LEFT = const(0)
|
|
LABEL_CENTER = const(1)
|
|
LABEL_RIGHT = const(2)
|
|
|
|
|
|
class Label(ui.Component):
|
|
def __init__(
|
|
self,
|
|
area: ui.Area,
|
|
content: str,
|
|
align: int = LABEL_LEFT,
|
|
style: int = ui.NORMAL,
|
|
) -> None:
|
|
super().__init__()
|
|
self.area = area
|
|
self.content = content
|
|
self.align = align
|
|
self.style = style
|
|
|
|
def on_render(self) -> None:
|
|
if self.repaint:
|
|
align = self.align
|
|
ax, ay, aw, ah = self.area
|
|
ui.display.bar(ax, ay, aw, ah, ui.BG)
|
|
tx = ax + aw // 2
|
|
ty = ay + ah // 2 + 8
|
|
if align is LABEL_LEFT:
|
|
ui.display.text(tx, ty, self.content, self.style, ui.FG, ui.BG)
|
|
elif align is LABEL_CENTER:
|
|
ui.display.text_center(tx, ty, self.content, self.style, ui.FG, ui.BG)
|
|
elif align is LABEL_RIGHT:
|
|
ui.display.text_right(tx, ty, self.content, self.style, ui.FG, ui.BG)
|
|
self.repaint = False
|
|
|
|
if __debug__:
|
|
|
|
def read_content(self) -> list[str]:
|
|
return [self.content]
|
|
|
|
|
|
def text_center_trim_left(
|
|
x: int, y: int, text: str, font: int = ui.NORMAL, width: int = ui.WIDTH - 16
|
|
) -> None:
|
|
if ui.display.text_width(text, font) <= width:
|
|
ui.display.text_center(x, y, text, font, ui.FG, ui.BG)
|
|
return
|
|
|
|
ELLIPSIS_WIDTH = ui.display.text_width("...", ui.BOLD)
|
|
if width < ELLIPSIS_WIDTH:
|
|
return
|
|
|
|
text_length = 0
|
|
for i in range(1, len(text)):
|
|
if ui.display.text_width(text[-i:], font) + ELLIPSIS_WIDTH > width:
|
|
text_length = i - 1
|
|
break
|
|
|
|
text_width = ui.display.text_width(text[-text_length:], font)
|
|
x -= (text_width + ELLIPSIS_WIDTH) // 2
|
|
ui.display.text(x, y, "...", ui.BOLD, ui.GREY, ui.BG)
|
|
x += ELLIPSIS_WIDTH
|
|
ui.display.text(x, y, text[-text_length:], font, ui.FG, ui.BG)
|
|
|
|
|
|
def text_center_trim_right(
|
|
x: int, y: int, text: str, font: int = ui.NORMAL, width: int = ui.WIDTH - 16
|
|
) -> None:
|
|
if ui.display.text_width(text, font) <= width:
|
|
ui.display.text_center(x, y, text, font, ui.FG, ui.BG)
|
|
return
|
|
|
|
ELLIPSIS_WIDTH = ui.display.text_width("...", ui.BOLD)
|
|
if width < ELLIPSIS_WIDTH:
|
|
return
|
|
|
|
text_length = 0
|
|
for i in range(1, len(text)):
|
|
if ui.display.text_width(text[:i], font) + ELLIPSIS_WIDTH > width:
|
|
text_length = i - 1
|
|
break
|
|
|
|
text_width = ui.display.text_width(text[:text_length], font)
|
|
x -= (text_width + ELLIPSIS_WIDTH) // 2
|
|
ui.display.text(x, y, text[:text_length], font, ui.FG, ui.BG)
|
|
x += text_width
|
|
ui.display.text(x, y, "...", ui.BOLD, ui.GREY, ui.BG)
|