refactor(core): improve render_text behavior

* use less memory due to copy-less rendering
* implement linebreaking on embedded \n
pull/1403/head
matejcik 3 years ago committed by matejcik
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…
Cancel
Save