Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[bugfix] Final word calc UI spacing fix; TextArea consistency/placement improvements #445

Merged
merged 5 commits into from
Aug 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 26 additions & 34 deletions src/seedsigner/gui/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -316,11 +317,9 @@ def __post_init__(self):
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

Expand All @@ -333,12 +332,14 @@ def __post_init__(self):

# 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

Expand All @@ -356,15 +357,7 @@ def __post_init__(self):

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):
Expand All @@ -373,26 +366,28 @@ def render(self):
# 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:
Expand All @@ -412,15 +407,20 @@ def render(self):
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))


Expand Down Expand Up @@ -1218,7 +1218,7 @@ def __post_init__(self):
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,
)
Expand Down Expand Up @@ -1262,6 +1262,7 @@ def __post_init__(self):
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")
)


Expand Down Expand Up @@ -1289,15 +1290,6 @@ def render_buttons(self):
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):
Expand Down
17 changes: 11 additions & 6 deletions src/seedsigner/gui/screens/tools_screens.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,23 +240,27 @@ def __post_init__(self):

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,
edge_padding=0,
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,
Expand All @@ -272,7 +276,7 @@ def __post_init__(self):
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
Expand All @@ -288,7 +292,7 @@ def __post_init__(self):
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,
))

Expand All @@ -308,6 +312,7 @@ def __post_init__(self):
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...
Expand Down
8 changes: 2 additions & 6 deletions src/seedsigner/gui/toast.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,12 @@ def __post_init__(self):
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):
Expand All @@ -67,7 +64,6 @@ def render(self):




class BaseToastOverlayManagerThread(BaseThread):
"""
The toast notification popup consists of a gui component (`ToastOverlay`) and this
Expand Down
5 changes: 5 additions & 0 deletions src/seedsigner/models/seed_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ def init_pending_mnemonic(self, num_words:int = 12):


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
Expand Down
80 changes: 43 additions & 37 deletions src/seedsigner/views/tools_views.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from dataclasses import dataclass
import hashlib
import os
import time
Expand Down Expand Up @@ -313,8 +314,7 @@ def run(self):

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
Expand All @@ -329,68 +329,74 @@ def run(self):
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)
Expand Down
Loading