diff --git a/src/seedsigner/gui/components.py b/src/seedsigner/gui/components.py index 2ee75395..7290a98e 100644 --- a/src/seedsigner/gui/components.py +++ b/src/seedsigner/gui/components.py @@ -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): diff --git a/src/seedsigner/gui/screens/tools_screens.py b/src/seedsigner/gui/screens/tools_screens.py index 4e8ad2f6..aa413fbd 100644 --- a/src/seedsigner/gui/screens/tools_screens.py +++ b/src/seedsigner/gui/screens/tools_screens.py @@ -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... diff --git a/src/seedsigner/gui/toast.py b/src/seedsigner/gui/toast.py index 7f4b3a50..6d37e0b9 100644 --- a/src/seedsigner/gui/toast.py +++ b/src/seedsigner/gui/toast.py @@ -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 diff --git a/src/seedsigner/models/seed_storage.py b/src/seedsigner/models/seed_storage.py index dcc30a7a..5b6c3049 100644 --- a/src/seedsigner/models/seed_storage.py +++ b/src/seedsigner/models/seed_storage.py @@ -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 diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index b8249f6f..89580e5e 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -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) diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index 9afac77d..88a010ba 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -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 = """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""" 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,