mirror of
https://github.com/trezor/trezor-firmware.git
synced 2025-01-22 05:10:56 +00:00
refactor(core): improve render_text behavior
* use less memory due to copy-less rendering * implement linebreaking on embedded \n
This commit is contained in:
parent
db5b65a420
commit
bbef9c650b
@ -142,7 +142,7 @@ async def show_warning_tx_staking_key_hash(
|
||||
|
||||
page2 = Text("Confirm transaction", ui.ICON_SEND, ui.GREEN)
|
||||
page2.normal("Staking key hash:")
|
||||
page2.mono(*chunks(hexlify(staking_key_hash), 17))
|
||||
page2.mono(*chunks(hexlify(staking_key_hash).decode(), 17))
|
||||
|
||||
page3 = Text("Confirm transaction", ui.ICON_SEND, ui.GREEN)
|
||||
page3.normal("Change amount:")
|
||||
|
@ -64,7 +64,7 @@ async def _request_on_host(ctx: wire.Context) -> str:
|
||||
text.normal("the passphrase!")
|
||||
await require_confirm(ctx, text, ButtonRequestType.Other)
|
||||
|
||||
text = Text("Hidden wallet", ICON_CONFIG)
|
||||
text = Text("Hidden wallet", ICON_CONFIG, break_words=True)
|
||||
text.normal("Use this passphrase?")
|
||||
text.br()
|
||||
text.mono(ack.passphrase)
|
||||
|
@ -59,13 +59,13 @@ class InfoConfirm(ui.Layout):
|
||||
ui.display.bar_radius(x, y, w, h, bg_color, ui.BG, ui.RADIUS)
|
||||
|
||||
# render the info text
|
||||
render_text( # type: ignore
|
||||
render_text(
|
||||
self.text,
|
||||
new_lines=False,
|
||||
max_lines=6,
|
||||
offset_y=y + TEXT_LINE_HEIGHT,
|
||||
offset_x=x + TEXT_MARGIN_LEFT - ui.VIEWX,
|
||||
offset_x_max=x + w - ui.VIEWX,
|
||||
line_width=w,
|
||||
fg=fg_color,
|
||||
bg=bg_color,
|
||||
)
|
||||
|
@ -3,7 +3,7 @@ from micropython import const
|
||||
from trezor import ui
|
||||
|
||||
if False:
|
||||
from typing import List, Union
|
||||
from typing import Any, List, Optional, Union
|
||||
|
||||
TEXT_HEADER_HEIGHT = const(48)
|
||||
TEXT_LINE_HEIGHT = const(26)
|
||||
@ -15,12 +15,164 @@ TEXT_MAX_LINES = const(5)
|
||||
BR = const(-256)
|
||||
BR_HALF = const(-257)
|
||||
|
||||
_FONTS = (ui.NORMAL, ui.BOLD, ui.MONO)
|
||||
|
||||
if False:
|
||||
TextContent = Union[str, int]
|
||||
|
||||
|
||||
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 = ui.WIDTH - TEXT_MARGIN_LEFT,
|
||||
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 = ui.WIDTH - TEXT_MARGIN_LEFT,
|
||||
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(
|
||||
words: List[TextContent],
|
||||
items: List[TextContent],
|
||||
new_lines: bool,
|
||||
max_lines: int,
|
||||
font: int = ui.NORMAL,
|
||||
@ -28,96 +180,172 @@ def render_text(
|
||||
bg: int = ui.BG,
|
||||
offset_x: int = TEXT_MARGIN_LEFT,
|
||||
offset_y: int = TEXT_HEADER_HEIGHT + TEXT_LINE_HEIGHT,
|
||||
offset_x_max: int = ui.WIDTH,
|
||||
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 overriden by font instructions
|
||||
in `items`.
|
||||
`fg` specifies default foreground color, which can also be overriden by instructions
|
||||
in `items`.
|
||||
`bg` specifies background color. This cannot be overriden.
|
||||
|
||||
`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).
|
||||
The item selected by `item_offset` must be a string.
|
||||
|
||||
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 = TEXT_HEADER_HEIGHT + (TEXT_LINE_HEIGHT * max_lines)
|
||||
|
||||
FONTS = (ui.NORMAL, ui.BOLD, ui.MONO)
|
||||
|
||||
# sizes of common glyphs
|
||||
SPACE = ui.display.text_width(" ", font)
|
||||
DASH = ui.display.text_width("-", ui.BOLD)
|
||||
ELLIPSIS = ui.display.text_width("...", ui.BOLD)
|
||||
offset_y_max = TEXT_HEADER_HEIGHT + (TEXT_LINE_HEIGHT * max_lines)
|
||||
span = _WORKING_SPAN
|
||||
|
||||
for word_index, word in enumerate(words):
|
||||
has_next_word = word_index < len(words) - 1
|
||||
for item_index in range(item_offset, len(items)):
|
||||
# load current item
|
||||
item = items[item_index]
|
||||
|
||||
if isinstance(word, int):
|
||||
if word is BR or word is BR_HALF:
|
||||
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:
|
||||
ui.display.text(offset_x, offset_y, "...", ui.BOLD, ui.GREY, bg)
|
||||
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 word is BR else TEXT_LINE_HEIGHT_HALF
|
||||
elif word in FONTS:
|
||||
offset_y += TEXT_LINE_HEIGHT if item is BR else TEXT_LINE_HEIGHT_HALF
|
||||
elif item in _FONTS:
|
||||
# change of font style
|
||||
font = word
|
||||
font = item
|
||||
SPACE = ui.display.text_width(" ", font)
|
||||
else:
|
||||
# change of foreground color
|
||||
fg = word
|
||||
fg = item
|
||||
continue
|
||||
|
||||
width = ui.display.text_width(word, font)
|
||||
|
||||
while offset_x + width > offset_x_max or (
|
||||
has_next_word and offset_y >= offset_y_max
|
||||
# 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
|
||||
and item_width + offset_x - INITIAL_OFFSET_X > line_width
|
||||
and "\n" not in item
|
||||
):
|
||||
beginning_of_line = offset_x == INITIAL_OFFSET_X
|
||||
word_fits_in_one_line = width < (offset_x_max - INITIAL_OFFSET_X)
|
||||
if (
|
||||
offset_y < offset_y_max
|
||||
and word_fits_in_one_line
|
||||
and not beginning_of_line
|
||||
):
|
||||
# line break
|
||||
offset_x = INITIAL_OFFSET_X
|
||||
offset_y += TEXT_LINE_HEIGHT
|
||||
break
|
||||
# word split
|
||||
if offset_y < offset_y_max:
|
||||
split = "-"
|
||||
splitw = DASH
|
||||
else:
|
||||
split = "..."
|
||||
splitw = ELLIPSIS
|
||||
# find span that fits
|
||||
for index in range(len(word) - 1, 0, -1):
|
||||
letter = word[index]
|
||||
width -= ui.display.text_width(letter, font)
|
||||
if offset_x + width + splitw < offset_x_max:
|
||||
break
|
||||
else:
|
||||
index = 0
|
||||
span = word[:index]
|
||||
# render word span
|
||||
ui.display.text(offset_x, offset_y, span, font, fg, bg)
|
||||
ui.display.text(offset_x + width, offset_y, split, ui.BOLD, ui.GREY, bg)
|
||||
# line break
|
||||
if offset_y >= offset_y_max:
|
||||
offset_y += TEXT_LINE_HEIGHT
|
||||
ui.display.text(INITIAL_OFFSET_X, offset_y, item, font, fg, bg)
|
||||
offset_x = INITIAL_OFFSET_X + item_width + SPACE
|
||||
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
|
||||
# continue with the rest
|
||||
word = word[index:]
|
||||
width = ui.display.text_width(word, font)
|
||||
|
||||
# render word
|
||||
ui.display.text(offset_x, offset_y, word, font, fg, bg)
|
||||
# render last chunk
|
||||
ui.display.text(offset_x, offset_y, item, font, fg, bg, span.start, span.length)
|
||||
|
||||
if new_lines and has_next_word:
|
||||
# line break
|
||||
if offset_y >= offset_y_max:
|
||||
ui.display.text(offset_x, offset_y, "...", ui.BOLD, ui.GREY, bg)
|
||||
return
|
||||
if new_lines:
|
||||
offset_x = INITIAL_OFFSET_X
|
||||
offset_y += TEXT_LINE_HEIGHT
|
||||
else:
|
||||
# shift cursor
|
||||
offset_x += width
|
||||
offset_x += SPACE
|
||||
elif span.width > 0:
|
||||
# only advance cursor if we actually rendered anything
|
||||
offset_x += span.width + SPACE
|
||||
|
||||
|
||||
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 # type: ignore
|
||||
|
||||
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: Optional[int] = None,
|
||||
) -> None:
|
||||
if length is None:
|
||||
length = len(string) - start
|
||||
self.screen_contents.append(string[start : start + length])
|
||||
|
||||
|
||||
class Text(ui.Component):
|
||||
@ -128,6 +356,11 @@ class Text(ui.Component):
|
||||
icon_color: int = ui.ORANGE_ICON,
|
||||
max_lines: int = TEXT_MAX_LINES,
|
||||
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
|
||||
@ -135,7 +368,12 @@ class Text(ui.Component):
|
||||
self.icon_color = icon_color
|
||||
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)
|
||||
@ -164,14 +402,30 @@ class Text(ui.Component):
|
||||
ui.BG,
|
||||
self.icon_color,
|
||||
)
|
||||
render_text(self.content, self.new_lines, self.max_lines)
|
||||
render_text(
|
||||
self.content,
|
||||
self.new_lines,
|
||||
self.max_lines,
|
||||
item_offset=self.content_offset,
|
||||
char_offset=self.char_offset,
|
||||
break_words=self.break_words,
|
||||
line_width=self.line_width,
|
||||
render_page_overflow=self.render_page_overflow,
|
||||
)
|
||||
self.repaint = False
|
||||
|
||||
if __debug__:
|
||||
|
||||
def read_content(self) -> List[str]:
|
||||
lines = [w for w in self.content if isinstance(w, str)]
|
||||
return [self.header_text] + lines[: self.max_lines]
|
||||
display_mock = DisplayMock()
|
||||
should_repaint = self.repaint
|
||||
try:
|
||||
with display_mock:
|
||||
self.repaint = True
|
||||
self.on_render()
|
||||
finally:
|
||||
self.repaint = should_repaint
|
||||
return display_mock.screen_contents
|
||||
|
||||
|
||||
LABEL_LEFT = const(0)
|
||||
|
Loading…
Reference in New Issue
Block a user