Merge pull request #445 from kdmukai/final_word_calc_screen_fix

[bugfix] Final word calc UI spacing fix; `TextArea` consistency/placement improvements
This commit is contained in:
Nick Klockenga
2023-08-22 17:16:40 -04:00
committed by GitHub
6 changed files with 95 additions and 84 deletions
+26 -34
View File
@@ -287,6 +287,7 @@ class TextArea(BaseComponent):
supersampling_factor: int = 1
auto_line_break: bool = True
allow_text_overflow: bool = False
height_ignores_below_baseline: bool = False # If True, characters that render below the baseline (e.g. "pqgy") will not affect the final height calculation
def __post_init__(self):
@@ -316,11 +317,9 @@ class TextArea(BaseComponent):
font = Fonts.get_font(self.font_name, self.font_size)
# Note: from the baseline anchor, `top` is a negative number while `bottom`
# conveys the pixels used below the baseline (e.g. in "py").
# For consistency, ensure we have a full-height character above baseline.
# Also include some "below baseline" chars.
measurement_chars = "Agjpqy"
(left, top, right, bottom) = font.getbbox(self.text + measurement_chars, anchor="ls")
# conveys the height of the pixels that rendered below the baseline, if any
# (e.g. "py" in "python").
(left, top, right, bottom) = font.getbbox(self.text, anchor="ls")
self.text_height_above_baseline = -1 * top
self.text_height_below_baseline = bottom
@@ -333,12 +332,14 @@ class TextArea(BaseComponent):
# Calculate the actual height
if len(self.text_lines) == 1:
total_text_height = self.text_height_above_baseline + self.text_height_below_baseline
total_text_height = self.text_height_above_baseline
if not self.height_ignores_below_baseline:
total_text_height += self.text_height_below_baseline
else:
# Multiply for the number of lines plus the spacer
total_text_height = self.text_height_above_baseline * len(self.text_lines) + self.line_spacing * (len(self.text_lines) - 1)
if re.findall(f"[gjpqy]", self.text_lines[-1]["text"]):
if not self.height_ignores_below_baseline and re.findall(f"[gjpqy]", self.text_lines[-1]["text"]):
# Last line has at least one char that dips below baseline
total_text_height += self.text_height_below_baseline
@@ -356,15 +357,7 @@ class TextArea(BaseComponent):
else:
# Vertically center the text's starting point
if len(self.text_lines) == 1:
# For consistency when used in TopNav and elsewhere, ignore the
# text's pixels below the baseline.
# In other words: "Home" and "Something" will get the same text_y,
# even though the "g" dips below baseline.
self.text_y += int(self.height - (total_text_height - self.text_height_below_baseline))/2
else:
# Vertically center for the full height.
self.text_y += int(self.height - (total_text_height))/2
self.text_y += int(self.height - total_text_height)/2
def render(self):
@@ -373,26 +366,28 @@ class TextArea(BaseComponent):
# Add a `resample_padding` above and below when supersampling to avoid edge
# effects (resized text that's right up against the top/bottom gets slightly
# dimmer at the edge otherwise).
# TODO: Store resulting super-sampled image as a member var in __post_init__ and
# just re-paste it here.
if self.font_size < 20 and (not self.supersampling_factor or self.supersampling_factor == 1):
self.supersampling_factor = 2
actual_text_height = self.height
if self.height_ignores_below_baseline:
# Even though we're ignoring the pixels below the baseline for spacing
# purposes, we have to make sure we don't crop those pixels out during the
# supersampling operations here.
actual_text_height += self.text_height_below_baseline
resample_padding = 10 if self.supersampling_factor > 1.0 else 0
img = Image.new(
"RGBA",
(
self.width * self.supersampling_factor,
(self.height + 2*resample_padding) * self.supersampling_factor
(actual_text_height + 2*resample_padding) * self.supersampling_factor
),
self.background_color
)
draw = ImageDraw.Draw(img)
# draw.line((0, resample_padding * self.supersampling_factor, self.width * self.supersampling_factor, resample_padding * self.supersampling_factor), fill="blue", width=1)
# draw.line((0, (resample_padding + self.height) * self.supersampling_factor, self.width * self.supersampling_factor, (resample_padding + self.height) * self.supersampling_factor), fill="red", width=1)
cur_y = (self.text_y + resample_padding) * self.supersampling_factor
supersampled_font = Fonts.get_font(self.font_name, int(self.supersampling_factor * self.font_size))
if self.is_text_centered:
@@ -412,15 +407,20 @@ class TextArea(BaseComponent):
text_x = self.min_text_x + int(line["text_width"]/2)
draw.text((text_x * self.supersampling_factor, cur_y), line["text"], fill=self.font_color, font=supersampled_font, anchor=anchor)
# Debugging: show the exact vertical extents of each line of text
# draw.line((0, cur_y - self.text_height_above_baseline * self.supersampling_factor, self.width * self.supersampling_factor, cur_y - self.text_height_above_baseline * self.supersampling_factor), fill="red", width=int(self.supersampling_factor))
# draw.line((0, cur_y, self.width * self.supersampling_factor, cur_y), fill="red", width=int(self.supersampling_factor))
cur_y += (self.text_height_above_baseline + self.line_spacing) * self.supersampling_factor
# Crop off the top_padding and resize the result down to onscreen size
if self.supersampling_factor > 1.0:
resized = img.resize((self.width, self.height + 2*resample_padding), Image.LANCZOS)
resized = img.resize((self.width, actual_text_height + 2*resample_padding), Image.LANCZOS)
sharpened = resized.filter(ImageFilter.SHARPEN)
# Crop args are actually (left, top, WIDTH, HEIGHT)
img = sharpened.crop((0, resample_padding, self.width, self.height + resample_padding))
img = sharpened.crop((0, resample_padding, self.width, actual_text_height + resample_padding))
self.canvas.paste(img, (self.screen_x, self.screen_y))
@@ -1218,7 +1218,7 @@ class TopNav(BaseComponent):
icon_name=SeedSignerIconConstants.BACK,
icon_size=GUIConstants.ICON_INLINE_FONT_SIZE,
screen_x=GUIConstants.EDGE_PADDING,
screen_y=GUIConstants.EDGE_PADDING,
screen_y=GUIConstants.EDGE_PADDING - 1, # Text can't perfectly vertically center relative to the button; shifting it down 1px looks better.
width=GUIConstants.TOP_NAV_BUTTON_SIZE,
height=GUIConstants.TOP_NAV_BUTTON_SIZE,
)
@@ -1262,6 +1262,7 @@ class TopNav(BaseComponent):
is_text_centered=True,
font_name=self.font_name,
font_size=self.font_size,
height_ignores_below_baseline=True, # Consistently vertically center text, ignoring chars that render below baseline (e.g. "pqyj")
)
@@ -1289,15 +1290,6 @@ class TopNav(BaseComponent):
self.right_button.is_selected = self.is_selected
self.right_button.render()
# self.image_draw.text(
# (self.text_x, self.text_y),
# self.text,
# font=self.font,
# fill=self.font_color,
# stroke_width=1,
# stroke_fill=GUIConstants.BACKGROUND_COLOR,
# )
def linear_interp(a, b, t):
+11 -6
View File
@@ -240,12 +240,13 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
self.components.append(TextArea(
text=f"""Your input: \"{selection_text}\"""",
screen_y=self.top_nav.height,
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING - 2, # Nudge to last line doesn't get too close to "Next" button
height_ignores_below_baseline=True, # Keep the next line (bits display) snugged up, regardless of text rendering below the baseline
))
# ...and that entropy's associated 11 bits
screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING
self.components.append(TextArea(
screen_y = self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING
first_bits_line = TextArea(
text=keeper_selected_bits,
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
font_size=bit_font_size,
@@ -253,10 +254,13 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
screen_x=bit_display_x,
screen_y=screen_y,
is_text_centered=False,
))
)
self.components.append(first_bits_line)
# Render the least significant bits that will be replaced by the checksum in a
# de-emphasized font color.
if "_" in discard_selected_bits:
screen_y += int(first_bits_line.height/2) # center the underscores vertically like hypens
self.components.append(TextArea(
text=discard_selected_bits,
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
@@ -272,7 +276,7 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
self.components.append(TextArea(
text="Checksum",
edge_padding=0,
screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING,
screen_y=first_bits_line.screen_y + first_bits_line.height + 2*GUIConstants.COMPONENT_PADDING,
))
# ...and its actual bits. Prepend spacers to keep vertical alignment
@@ -288,7 +292,7 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
font_size=bit_font_size,
edge_padding=0,
screen_x=bit_display_x,
screen_y=screen_y,
screen_y=screen_y + int(first_bits_line.height/2), # center the underscores vertically like hypens
is_text_centered=False,
))
@@ -308,6 +312,7 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
self.components.append(TextArea(
text=f"""Final Word: \"{self.actual_final_word}\"""",
screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING,
height_ignores_below_baseline=True, # Keep the next line (bits display) snugged up, regardless of text rendering below the baseline
))
# Once again show the bits that came from the user's entropy...
+2 -6
View File
@@ -38,15 +38,12 @@ class ToastOverlay(BaseComponent):
auto_line_break=True,
width=self.canvas_width - self.icon.screen_x - self.icon.width - GUIConstants.COMPONENT_PADDING - self.outline_thickness,
screen_x=self.icon.screen_x + self.icon.width + GUIConstants.COMPONENT_PADDING,
allow_text_overflow=False
allow_text_overflow=False,
)
# Single-line toast messages need their vertical centering nudged down to account
# for TextArea including below the baseline in its height calculation.
below_baseline = self.label.text_height_below_baseline if len(self.label.text_lines) == 1 else 0
# Vertically center the message within the toast (for single- or multi-line
# messages).
self.label.screen_y = self.canvas_height - self.height + self.outline_thickness + int((self.height - 2*self.outline_thickness - (self.label.height - below_baseline))/2)
self.label.screen_y = self.canvas_height - self.height + self.outline_thickness + int((self.height - 2*self.outline_thickness - self.label.height)/2)
def render(self):
@@ -67,7 +64,6 @@ class ToastOverlay(BaseComponent):
class BaseToastOverlayManagerThread(BaseThread):
"""
The toast notification popup consists of a gui component (`ToastOverlay`) and this
+5
View File
@@ -63,6 +63,11 @@ class SeedStorage:
def update_pending_mnemonic(self, word: str, index: int):
"""
Replaces the nth word in the pending mnemonic.
* may specify a negative `index` (e.g. -1 is the last word).
"""
if index >= len(self._pending_mnemonic):
raise Exception(f"index {index} is too high")
self._pending_mnemonic[index] = word
+43 -37
View File
@@ -1,3 +1,4 @@
from dataclasses import dataclass
import hashlib
import os
import time
@@ -313,8 +314,7 @@ class ToolsCalcFinalWordFinalizePromptView(View):
class ToolsCalcFinalWordCoinFlipsView(View):
def run(self):
mnemonic = self.controller.storage.pending_mnemonic
mnemonic_length = len(mnemonic)
mnemonic_length = len(self.controller.storage.pending_mnemonic)
if mnemonic_length == 12:
total_flips = 7
@@ -329,68 +329,74 @@ class ToolsCalcFinalWordCoinFlipsView(View):
return Destination(BackStackView)
else:
print(ret_val)
binary_string = ret_val + "0" * (11 - total_flips)
wordlist_index = int(binary_string, 2)
wordlist = Seed.get_wordlist(self.controller.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE))
word = wordlist[wordlist_index]
self.controller.storage.update_pending_mnemonic(word, mnemonic_length - 1)
return Destination(ToolsCalcFinalWordShowFinalWordView, view_args=dict(coin_flips=ret_val))
class ToolsCalcFinalWordShowFinalWordView(View):
def __init__(self, coin_flips=None):
def __init__(self, coin_flips: str = None):
super().__init__()
self.coin_flips = coin_flips
def run(self):
# Construct the actual final word. The user's selected_final_word
# contributes:
# * 3 bits to a 24-word seed (plus 8-bit checksum)
# * 7 bits to a 12-word seed (plus 4-bit checksum)
from seedsigner.helpers import mnemonic_generation
mnemonic = self.controller.storage.pending_mnemonic
mnemonic_length = len(mnemonic)
wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)
wordlist = Seed.get_wordlist(wordlist_language_code)
# Prep the user's selected word / coin flips and the actual final word for
# the display.
if coin_flips:
self.selected_final_word = None
self.selected_final_bits = coin_flips
else:
# Convert the user's final word selection into its binary index equivalent
self.selected_final_word = self.controller.storage.pending_mnemonic[-1]
self.selected_final_bits = format(wordlist.index(self.selected_final_word), '011b')
if coin_flips:
# fill the last bits (what will eventually be the checksum) with zeros
binary_string = coin_flips + "0" * (11 - len(coin_flips))
# retrieve the matching word for the resulting index
wordlist_index = int(binary_string, 2)
wordlist = Seed.get_wordlist(self.controller.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE))
word = wordlist[wordlist_index]
# update the pending mnemonic with our new "final" (pre-checksum) word
self.controller.storage.update_pending_mnemonic(word, -1)
# Now calculate the REAL final word (has a proper checksum)
final_mnemonic = mnemonic_generation.calculate_checksum(
mnemonic=self.controller.storage.pending_mnemonic,
wordlist_language_code=wordlist_language_code,
)
self.controller.storage.update_pending_mnemonic(final_mnemonic[-1], mnemonic_length - 1)
# Prep the user's selected word (if there was one) and the actual final word for
# the display.
if self.coin_flips:
selected_final_word = None
selected_final_bits = self.coin_flips
else:
# Convert the user's final word selection into its binary index equivalent
selected_final_word = mnemonic[-1]
selected_final_bits = format(wordlist.index(selected_final_word), '011b')
# Update our pending mnemonic with the real final word
self.controller.storage.update_pending_mnemonic(final_mnemonic[-1], -1)
mnemonic = self.controller.storage.pending_mnemonic
mnemonic_length = len(mnemonic)
# And grab the actual final word's checksum bits
actual_final_word = self.controller.storage.pending_mnemonic[-1]
if mnemonic_length == 12:
checksum_bits = format(wordlist.index(actual_final_word), '011b')[-4:]
else:
checksum_bits = format(wordlist.index(actual_final_word), '011b')[-8:]
self.actual_final_word = self.controller.storage.pending_mnemonic[-1]
num_checksum_bits = 4 if mnemonic_length == 12 else 8
self.checksum_bits = format(wordlist.index(self.actual_final_word), '011b')[-num_checksum_bits:]
def run(self):
NEXT = "Next"
button_data = [NEXT]
selected_menu_num = ToolsCalcFinalWordScreen(
selected_menu_num = self.run_screen(
ToolsCalcFinalWordScreen,
title="Final Word Calc",
button_data=button_data,
selected_final_word=selected_final_word,
selected_final_bits=selected_final_bits,
checksum_bits=checksum_bits,
actual_final_word=actual_final_word,
).display()
selected_final_word=self.selected_final_word,
selected_final_bits=self.selected_final_bits,
checksum_bits=self.checksum_bits,
actual_final_word=self.actual_final_word,
)
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
+8 -1
View File
@@ -71,6 +71,12 @@ def test_generate_screenshots(target_locale):
controller.storage.set_pending_seed(seed_24)
UnhandledExceptionViewFood = ["IndexError", "line 1, in some_buggy_code.py", "list index out of range"]
# Pending mnemonic for ToolsCalcFinalWordShowFinalWordView
controller.storage.init_pending_mnemonic(num_words=12)
for i, word in enumerate(mnemonic_12[:11]):
controller.storage.update_pending_mnemonic(word=word, index=i)
controller.storage.update_pending_mnemonic(word="satoshi", index=11) # random last word; not supposed to be a valid checksum (yet)
# Load a PSBT into memory
BASE64_PSBT_1 = """cHNidP8BAP06AQIAAAAC5l4E3oEjI+H0im8t/K2nLmF5iJFdKEiuQs8ESveWJKcAAAAAAP3///8iBZMRhYIq4s/LmnTmKBi79M8ITirmsbO++63evK4utwAAAAAA/f///wZYQuoDAAAAACIAIAW5jm3UnC5fyjKCUZ8LTzjENtb/ioRTaBMXeSXsB3n+bK2fCgAAAAAWABReJY7akT1+d+jx475yBRWORdBd7VxbUgUAAAAAFgAU4wj9I/jB3GjNQudNZAca+7g9R16iWtYOAAAAABYAFIotPApLZlfscg8f3ppKqO3qA5nv7BnMFAAAAAAiACAs6SGc8qv4FwuNl0G0SpMZG8ODUEk5RXiWUcuzzw5iaRSfAhMAAAAAIgAgW0f5QxQIgVCGQqKzsvfkXZjUxdFop5sfez6Pt8mUbmZ1AgAAAAEAkgIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////BQIRAgEB/////wJAvkAlAAAAACIAIIRPoo2LvkrwrhrYFhLhlP43izxbA4Eo6Y6iFFiQYdXRAAAAAAAAAAAmaiSqIant4vYcP3HR3v0/qZnfo2lTdVxpBol5mWK0i+vYNpdOjPkAAAAAAQErQL5AJQAAAAAiACCET6KNi75K8K4a2BYS4ZT+N4s8WwOBKOmOohRYkGHV0QEFR1EhArGhNdUqlR4BAOLGTMrY2ZJYTQNRudp7fU7i8crRJqgEIQNDxn7PjUzvsP6KYw4s7dmoZE0qO1K6MaM+2ScRZ7hyxFKuIgYCsaE11SqVHgEA4sZMytjZklhNA1G52nt9TuLxytEmqAQcc8XaCjAAAIABAACAAAAAgAIAAIAAAAAAAwAAACIGA0PGfs+NTO+w/opjDizt2ahkTSo7Uroxoz7ZJxFnuHLEHCK94akwAACAAQAAgAAAAIACAACAAAAAAAMAAAAAAQCSAgAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8FAhACAQH/////AkC+QCUAAAAAIgAghE+ijYu+SvCuGtgWEuGU/jeLPFsDgSjpjqIUWJBh1dEAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QAAAAABAStAvkAlAAAAACIAIIRPoo2LvkrwrhrYFhLhlP43izxbA4Eo6Y6iFFiQYdXRAQVHUSECsaE11SqVHgEA4sZMytjZklhNA1G52nt9TuLxytEmqAQhA0PGfs+NTO+w/opjDizt2ahkTSo7Uroxoz7ZJxFnuHLEUq4iBgKxoTXVKpUeAQDixkzK2NmSWE0DUbnae31O4vHK0SaoBBxzxdoKMAAAgAEAAIAAAACAAgAAgAAAAAADAAAAIgYDQ8Z+z41M77D+imMOLO3ZqGRNKjtSujGjPtknEWe4csQcIr3hqTAAAIABAACAAAAAgAIAAIAAAAAAAwAAAAABAUdRIQJ5XLCBS0hdo4NANq4lNhimzhyHj7dvObmPAwNj8L2xASEC9mwwoH28/WHnxbb6z05sJ/lHuvrLs/wOooHgFn5ulI1SriICAnlcsIFLSF2jg0A2riU2GKbOHIePt285uY8DA2PwvbEBHCK94akwAACAAQAAgAAAAIACAACAAQAAAAEAAAAiAgL2bDCgfbz9YefFtvrPTmwn+Ue6+suz/A6igeAWfm6UjRxzxdoKMAAAgAEAAIAAAACAAgAAgAEAAAABAAAAAAAAAAEBR1EhAgpbWcEh7rgvRE5UaCcqzWL/TR1B/DS8UeZsKVEvuKLrIQOwLg0emiQbbxafIh69Xjtpj4eclsMhKq1y/7vYDdE7LVKuIgICCltZwSHuuC9ETlRoJyrNYv9NHUH8NLxR5mwpUS+4ouscc8XaCjAAAIABAACAAAAAgAIAAIAAAAAABQAAACICA7AuDR6aJBtvFp8iHr1eO2mPh5yWwyEqrXL/u9gN0TstHCK94akwAACAAQAAgAAAAIACAACAAAAAAAUAAAAAAQFHUSECk50GLh/YhZaLJkDq/dugU3H/WvE6rTgQuY6N57pI4ykhA/H8MdLVP9SA/Hg8l3hvibSaC1bCBzwz7kTW+rsEZ8uFUq4iAgKTnQYuH9iFlosmQOr926BTcf9a8TqtOBC5jo3nukjjKRxzxdoKMAAAgAEAAIAAAACAAgAAgAAAAAAGAAAAIgID8fwx0tU/1ID8eDyXeG+JtJoLVsIHPDPuRNb6uwRny4UcIr3hqTAAAIABAACAAAAAgAIAAIAAAAAABgAAAAA="""
decoder = DecodeQR()
@@ -207,7 +213,8 @@ def test_generate_screenshots(target_locale):
tools_views.ToolsCalcFinalWordNumWordsView,
tools_views.ToolsCalcFinalWordFinalizePromptView,
tools_views.ToolsCalcFinalWordCoinFlipsView,
#(tools_views.ToolsCalcFinalWordShowFinalWordView, dict(coin_flips=3)),
(tools_views.ToolsCalcFinalWordShowFinalWordView, {}, "ToolsCalcFinalWordShowFinalWordView_pick_word"),
(tools_views.ToolsCalcFinalWordShowFinalWordView, dict(coin_flips="0010101"), "ToolsCalcFinalWordShowFinalWordView_coin_flips"),
#tools_views.ToolsCalcFinalWordDoneView,
tools_views.ToolsAddressExplorerSelectSourceView,
tools_views.ToolsAddressExplorerAddressTypeView,