From 9905f90df57034aaae4db02f5474a9a77443e7e6 Mon Sep 17 00:00:00 2001 From: bitcoisas Date: Thu, 16 Apr 2026 12:24:34 -0300 Subject: [PATCH 1/5] feat: add vertical export methods to Stackbit 1248 Add grouped and compact export methods. --- src/krux/pages/home_pages/mnemonic_backup.py | 75 +++++- src/krux/pages/stack_1248.py | 244 +++++++++++++++++++ 2 files changed, 318 insertions(+), 1 deletion(-) diff --git a/src/krux/pages/home_pages/mnemonic_backup.py b/src/krux/pages/home_pages/mnemonic_backup.py index a0581ca76..88b1f50d0 100644 --- a/src/krux/pages/home_pages/mnemonic_backup.py +++ b/src/krux/pages/home_pages/mnemonic_backup.py @@ -178,7 +178,19 @@ def display_seed_qr(self, binary=False): return seed_qr_view.display_qr() def stackbit(self): - """Displays which numbers 1248 user should punch on 1248 steel card""" + """Displays layout selection submenu for Stackbit 1248 backup""" + submenu = Menu( + self.ctx, + [ + (t("Standard"), self._stackbit_standard), + (t("Vertical"), self._stackbit_vertical), + ], + ) + submenu.run_loop() + return MENU_CONTINUE + + def _stackbit_standard(self): + """Displays Stackbit 1248 in standard (horizontal) format — 6 words per page""" from ..stack_1248 import Stackbit stackbit = Stackbit(self.ctx) @@ -198,6 +210,67 @@ def stackbit(self): self.ctx.display.clear() return MENU_CONTINUE + def _stackbit_vertical(self): + """Dispatches to the grouped or minimal layout based on the current device""" + if kboard.has_minimal_display: + return self._stackbit_vertical_compact() + return self._stackbit_vertical_default() + + def _stackbit_vertical_default(self): + """Draws vertical Stackbit 1248 layout with 2 words per group, 4 words per page""" + from ..stack_1248 import Stackbit + + stackbit = Stackbit(self.ctx) + words = self.ctx.wallet.key.mnemonic.split(" ") + total_words = len(words) + + words_per_group = 2 + + groups_per_page = 2 + block_h = 7 * FONT_HEIGHT + 2 + group_gap = max(2, FONT_HEIGHT // 4) + + word_index = 1 + while word_index <= total_words: + self.ctx.display.draw_hcentered_text("Stackbit 1248") + y_offset = 2 * FONT_HEIGHT + for _ in range(groups_per_page): + if word_index > total_words: + break + group = [] + for _ in range(words_per_group): + if word_index > total_words: + break + group.append((word_index, words[word_index - 1])) + word_index += 1 + stackbit.export_1248_vertical_grouped(y_offset, group) + y_offset += block_h + group_gap + self.ctx.input.wait_for_button() + self.ctx.display.clear() + return MENU_CONTINUE + + def _stackbit_vertical_compact(self): + """Draws compact Stackbit 1248 layout for M5StickV, 6 words per page""" + from ..stack_1248 import Stackbit + + stackbit = Stackbit(self.ctx) + words = self.ctx.wallet.key.mnemonic.split(" ") + total_words = len(words) + words_per_page = 6 + word_index = 0 + + while word_index < total_words: + self.ctx.display.draw_hcentered_text("Stackbit 1248") + page_words = [] + for _ in range(words_per_page): + if word_index < total_words: + page_words.append((word_index + 1, words[word_index])) + word_index += 1 + stackbit.export_1248_vertical_compact(page_words, 2 * FONT_HEIGHT) + self.ctx.input.wait_for_button() + self.ctx.display.clear() + return MENU_CONTINUE + def tiny_seed(self): """Displays the seed in Tinyseed format""" from ..tiny_seed import TinySeed diff --git a/src/krux/pages/stack_1248.py b/src/krux/pages/stack_1248.py index 0eaa700f2..a86b17b63 100644 --- a/src/krux/pages/stack_1248.py +++ b/src/krux/pages/stack_1248.py @@ -38,6 +38,8 @@ STACKBIT_GO_INDEX = 38 STACKBIT_ESC_INDEX = 35 STACKBIT_MAX_INDEX = 13 +BIT_WEIGHTS = (1, 2, 4, 8) +BIT_LABELS = ("1", "2", "4", "8") class Stackbit(Page): @@ -342,6 +344,248 @@ def _draw_menu(self): ) x_offset += 3 * self.x_pad + def export_1248_vertical_compact( + self, words_list, y_start + ): # pylint: disable=too-many-locals + """Draws compact Stackbit 1248 grids for minimal displays, 2 words per row""" + n_word_cols = 2 + n_cols_per_word = 4 + word_col_gap = 4 + row_gap = 3 + header_h = FONT_HEIGHT + + label_w = FONT_WIDTH + x_start = MINIMAL_PADDING + right_pad = MINIMAL_PADDING + + available_w = ( + self.ctx.display.width() - x_start - label_w - word_col_gap - right_pad + ) + cell_w = available_w // (n_word_cols * n_cols_per_word) + + n_rows = (len(words_list) + n_word_cols - 1) // n_word_cols + available_h = self.ctx.display.height() - y_start + cell_h = (available_h - n_rows * header_h - (n_rows - 1) * row_gap) // ( + n_rows * 4 + ) + cell_h = min(cell_h, FONT_HEIGHT) + + grid_w = n_cols_per_word * cell_w + row_total_h = header_h + 4 * cell_h + + dot_size = max(min(cell_w, cell_h) - 6, 1) + radius = dot_size // 2 + + x_label = x_start + x_grid_left = x_label + label_w + x_grid_right = x_grid_left + grid_w + word_col_gap + + for row_idx in range(n_rows): + word_pair = words_list[row_idx * n_word_cols : (row_idx + 1) * n_word_cols] + y_row = y_start + row_idx * (row_total_h + row_gap) + y_grid = y_row + header_h # grid starts below the header + grid_h = 4 * cell_h + + # Word-number headers + for col_idx, (word_idx, _) in enumerate(word_pair): + x_grid = x_grid_left if col_idx == 0 else x_grid_right + self.ctx.display.fill_rectangle( + x_grid, y_row, grid_w, header_h, theme.disabled_color + ) + x_num = x_grid + (grid_w - 2 * FONT_WIDTH) // 2 + self.ctx.display.draw_string( + x_num, + y_row, + "%02d" % word_idx, + theme.fg_color, + theme.disabled_color, + ) + + # Row labels + for bit_row, label in enumerate(BIT_LABELS): + self.ctx.display.draw_string( + x_label, + y_grid + bit_row * cell_h, + label, + theme.fg_color, + ) + + for col_idx, (_, word) in enumerate(word_pair): + x_grid = x_grid_left if col_idx == 0 else x_grid_right + + # Outer grid border + self.ctx.display.draw_line( + x_grid, y_grid, x_grid + grid_w, y_grid, theme.frame_color + ) + self.ctx.display.draw_line( + x_grid, + y_grid + grid_h, + x_grid + grid_w, + y_grid + grid_h, + theme.frame_color, + ) + self.ctx.display.draw_line( + x_grid, y_grid, x_grid, y_grid + grid_h, theme.frame_color + ) + self.ctx.display.draw_line( + x_grid + grid_w, + y_grid, + x_grid + grid_w, + y_grid + grid_h, + theme.frame_color, + ) + + # Internal vertical column dividers + for c in range(1, n_cols_per_word): + x_line = x_grid + c * cell_w + self.ctx.display.draw_line( + x_line, y_grid, x_line, y_grid + grid_h, theme.frame_color + ) + + # Internal horizontal row dividers + for r in range(1, 4): + y_line = y_grid + r * cell_h + self.ctx.display.draw_line( + x_grid, y_line, x_grid + grid_w, y_line, theme.frame_color + ) + + # Punched marks + digits, _ = self._word_to_digits(word) + for col, d in enumerate(digits): + x_col = x_grid + col * cell_w + for bit_row, bit_val in enumerate(BIT_WEIGHTS): + if d & bit_val: + x_dot = x_col + (cell_w - dot_size) // 2 + y_dot = y_grid + bit_row * cell_h + (cell_h - dot_size) // 2 + self.ctx.display.fill_rectangle( + x_dot, + y_dot, + dot_size, + dot_size, + theme.highlight_color, + radius, + ) + + def export_1248_vertical_grouped( + self, y_offset, words_group + ): # pylint: disable=too-many-locals + """Draws grouped Stackbit 1248 grids with individual bordered sections per word""" + if kboard.is_m5stickv: + self.x_offset = MINIMAL_PADDING + else: + self.x_offset = DEFAULT_PADDING + + n_words = len(words_group) + n_cols_per_word = 4 + n_gaps = n_words - 1 + word_gap = 4 + + label_w = FONT_WIDTH + 2 + available = self.ctx.display.width() - self.x_offset - DEFAULT_PADDING - label_w + cell_w = (available - n_gaps * word_gap) // (n_words * n_cols_per_word) + cell_h = FONT_HEIGHT + header_h = FONT_HEIGHT + + word_block_w = n_cols_per_word * cell_w + word_stride = word_block_w + word_gap + + x_label = self.x_offset + x_grid0 = x_label + label_w + grid_h = 4 * cell_h + y_grid = y_offset + header_h + + # Row labels + for i, label in enumerate(BIT_LABELS): + self.ctx.display.draw_string( + x_label, y_grid + i * cell_h, label, theme.fg_color + ) + + # Per-word header and grid + for i, (word_idx, _) in enumerate(words_group): + x_sec = x_grid0 + i * word_stride + + # Header background with centred word number + self.ctx.display.fill_rectangle( + x_sec, y_offset, word_block_w, header_h, theme.disabled_color + ) + x_num = x_sec + (word_block_w - 2 * FONT_WIDTH) // 2 + self.ctx.display.draw_string( + x_num, y_offset, "%02d" % word_idx, theme.fg_color, theme.disabled_color + ) + + # Outer border of this word's grid + self.ctx.display.draw_line( + x_sec, y_grid, x_sec + word_block_w, y_grid, theme.frame_color + ) + self.ctx.display.draw_line( + x_sec, + y_grid + grid_h, + x_sec + word_block_w, + y_grid + grid_h, + theme.frame_color, + ) + self.ctx.display.draw_line( + x_sec, y_grid, x_sec, y_grid + grid_h, theme.frame_color + ) + self.ctx.display.draw_line( + x_sec + word_block_w, + y_grid, + x_sec + word_block_w, + y_grid + grid_h, + theme.frame_color, + ) + + # Vertical column dividers + for col in range(1, n_cols_per_word): + x_line = x_sec + col * cell_w + self.ctx.display.draw_line( + x_line, y_grid, x_line, y_grid + grid_h, theme.frame_color + ) + + # Horizontal row dividers + if i == 0: + for row in range(1, 4): + y_line = y_grid + row * cell_h + for j in range(n_words): + xs = x_grid0 + j * word_stride + self.ctx.display.draw_line( + xs, y_line, xs + word_block_w, y_line, theme.frame_color + ) + + # Punched marks + dot_size = max(min(cell_w, cell_h) - 6, 1) + radius = dot_size // 2 + + for w_i, (_, word) in enumerate(words_group): + x_sec = x_grid0 + w_i * word_stride + digits, _ = self._word_to_digits(word) + for col, d in enumerate(digits): + x_col = x_sec + col * cell_w + for row_idx, bit_val in enumerate(BIT_WEIGHTS): + if d & bit_val: + x_dot = x_col + (cell_w - dot_size) // 2 + y_dot = y_grid + row_idx * cell_h + (cell_h - dot_size) // 2 + self.ctx.display.fill_rectangle( + x_dot, + y_dot, + dot_size, + dot_size, + theme.highlight_color, + radius, + ) + + # Code and word name below each section + y_text = y_grid + grid_h + 2 + for i, (_, word) in enumerate(words_group): + x_sec = x_grid0 + i * word_stride + _, digits_str = self._word_to_digits(word) + self.ctx.display.draw_string( + x_sec, y_text, digits_str, theme.highlight_color + ) + self.ctx.display.draw_string( + x_sec, y_text + FONT_HEIGHT, word, theme.disabled_color + ) + def digits_to_word(self, digits): """Returns seed word respective to digits BIP39 dictionaty position""" word_number = int("".join(str(num) for num in digits)) From 612b8f872c59ef1f3e6a228d4edb473d34b70a53 Mon Sep 17 00:00:00 2001 From: bitcoisas Date: Thu, 16 Apr 2026 12:24:52 -0300 Subject: [PATCH 2/5] test: add tests for Stackbit 1248 vertical layout Test grouped/compact layouts + 24-word pagination. --- tests/pages/test_stackbit.py | 147 +++++++++++++++++++++++++++-------- 1 file changed, 115 insertions(+), 32 deletions(-) diff --git a/tests/pages/test_stackbit.py b/tests/pages/test_stackbit.py index e695db784..7be313213 100644 --- a/tests/pages/test_stackbit.py +++ b/tests/pages/test_stackbit.py @@ -1,7 +1,8 @@ from .home_pages.test_home import tdata, create_ctx -def test_export_mnemonic_stackbit(mocker, m5stickv, tdata): +def test_export_mnemonic_stackbit_standard(mocker, m5stickv, tdata): + """Standard layout: 6 words per page, 4 pages for 24-word mnemonic""" from krux.pages.home_pages.mnemonic_backup import MnemonicsView from krux.wallet import Wallet from krux.input import BUTTON_ENTER, BUTTON_PAGE @@ -10,32 +11,34 @@ def test_export_mnemonic_stackbit(mocker, m5stickv, tdata): Wallet(tdata.SINGLESIG_24_WORD_KEY), None, [ - BUTTON_PAGE, - BUTTON_PAGE, - BUTTON_ENTER, # Other - BUTTON_PAGE, - BUTTON_PAGE, - BUTTON_ENTER, # Open Stackbit - BUTTON_ENTER, # PG2 - BUTTON_ENTER, # PG3 - BUTTON_ENTER, # PG4 - BUTTON_ENTER, # Leave - BUTTON_PAGE, # Go to "Back" - BUTTON_PAGE, - BUTTON_ENTER, # click on back to return Mnemonic Backup - BUTTON_PAGE, - BUTTON_ENTER, # click on back to return to home init screen + *([BUTTON_PAGE] * 2), # Go to "Other Formats" + BUTTON_ENTER, # Select "Other Formats" + *([BUTTON_PAGE] * 2), # Go to "Open Stackbit" + *( + [BUTTON_ENTER] * 6 + ), # Select "Open Stackbit", "Standard", PG2, PG3, PG4, leave + *([BUTTON_PAGE] * 2), # Go to "Back" in Stackbit submenu + BUTTON_ENTER, # Select "Back" from Stackbit submenu + *([BUTTON_PAGE] * 2), # Go to "Back" in Other Formats + BUTTON_ENTER, # Select "Back" from Other Formats + BUTTON_PAGE, # Go to "Back" in mnemonic menu + BUTTON_ENTER, # Select "Back" ], ] ctx = create_ctx(mocker, case[2], case[0], case[1]) mnemonics = MnemonicsView(ctx) mocker.spy(mnemonics, "stackbit") + mocker.spy(mnemonics, "_stackbit_standard") + mocker.spy(mnemonics, "_stackbit_vertical_compact") mnemonics.mnemonic() mnemonics.stackbit.assert_called_once() + mnemonics._stackbit_standard.assert_called_once() + mnemonics._stackbit_vertical_compact.assert_not_called() assert ctx.input.wait_for_button.call_count == len(case[2]) -def test_export_mnemonic_stackbit_amigo(mocker, amigo, tdata): +def test_export_mnemonic_stackbit_standard_amigo(mocker, amigo, tdata): + """Standard layout on Amigo: 6 words per page, 4 pages for 24-word mnemonic""" from krux.pages.home_pages.mnemonic_backup import MnemonicsView from krux.wallet import Wallet from krux.input import BUTTON_ENTER, BUTTON_PAGE @@ -44,28 +47,108 @@ def test_export_mnemonic_stackbit_amigo(mocker, amigo, tdata): Wallet(tdata.SINGLESIG_24_WORD_KEY), None, [ - BUTTON_PAGE, - BUTTON_PAGE, - BUTTON_ENTER, # Other - BUTTON_PAGE, - BUTTON_PAGE, - BUTTON_ENTER, # Open Stackbit - BUTTON_ENTER, # PG2 - BUTTON_ENTER, # PG3 - BUTTON_ENTER, # PG4 - BUTTON_ENTER, # Leave - BUTTON_PAGE, # Go to "Back" - BUTTON_PAGE, - BUTTON_ENTER, # click on back to return Mnemonic Backup - BUTTON_PAGE, - BUTTON_ENTER, # click on back to return to home init screen + *([BUTTON_PAGE] * 2), # Go to "Other Formats" + BUTTON_ENTER, # Select "Other Formats" + *([BUTTON_PAGE] * 2), # Go to "Open Stackbit" + *( + [BUTTON_ENTER] * 6 + ), # Select "Open Stackbit", "Standard", PG2, PG3, PG4, leave + *([BUTTON_PAGE] * 2), # Go to "Back" in Stackbit submenu + BUTTON_ENTER, # Select "Back" from Stackbit submenu + *([BUTTON_PAGE] * 2), # Go to "Back" in Other Formats + BUTTON_ENTER, # Select "Back" from Other Formats + BUTTON_PAGE, # Go to "Back" in mnemonic menu + BUTTON_ENTER, # Select "Back" ], ] ctx = create_ctx(mocker, case[2], case[0], case[1]) mnemonics = MnemonicsView(ctx) mocker.spy(mnemonics, "stackbit") + mocker.spy(mnemonics, "_stackbit_standard") mnemonics.mnemonic() mnemonics.stackbit.assert_called_once() + mnemonics._stackbit_standard.assert_called_once() + assert ctx.input.wait_for_button.call_count == len(case[2]) + + +def test_export_mnemonic_stackbit_vertical(mocker, amigo, tdata): + """Grouped layout on Amigo: 2 words/group, 4 words/page, 6 pages for 24-word mnemonic. + + Amigo uses FONT_WIDTH=12, so 3 words/group would overflow the word name text. + The layout auto-selects 2 words/group → 4 words/page → 6 pages. + """ + from krux.pages.home_pages.mnemonic_backup import MnemonicsView + from krux.wallet import Wallet + from krux.input import BUTTON_ENTER, BUTTON_PAGE + + case = [ + Wallet(tdata.SINGLESIG_24_WORD_KEY), + None, + [ + *([BUTTON_PAGE] * 2), # Go to "Other Formats" + BUTTON_ENTER, # Select "Other Formats" + *([BUTTON_PAGE] * 2), # Go to "Open Stackbit" + BUTTON_ENTER, # Select "Open Stackbit" + BUTTON_PAGE, # Go to "Vertical" + BUTTON_ENTER, # Select "Vertical" + *([BUTTON_ENTER] * 6), # Advance 6 pages + BUTTON_PAGE, # Go to "Back" in Stackbit submenu + BUTTON_ENTER, # Select "Back" from Stackbit submenu + *([BUTTON_PAGE] * 2), # Go to "Back" in Other Formats + BUTTON_ENTER, # Select "Back" from Other Formats + BUTTON_PAGE, # Go to "Back" in mnemonic menu + BUTTON_ENTER, # Select "Back" + ], + ] + ctx = create_ctx(mocker, case[2], case[0], case[1]) + mnemonics = MnemonicsView(ctx) + mocker.spy(mnemonics, "stackbit") + mocker.spy(mnemonics, "_stackbit_vertical") + mocker.spy(mnemonics, "_stackbit_vertical_default") + mnemonics.mnemonic() + mnemonics.stackbit.assert_called_once() + mnemonics._stackbit_vertical.assert_called_once() + mnemonics._stackbit_vertical_default.assert_called_once() + assert ctx.input.wait_for_button.call_count == len(case[2]) + + +def test_export_mnemonic_stackbit_vertical_compact(mocker, m5stickv, tdata): + """Dense layout on M5StickV: 6 words per page, 4 pages for 24-word mnemonic. + + 2 words side-by-side × 3 rows, no word names or BIP39 codes. + """ + from krux.pages.home_pages.mnemonic_backup import MnemonicsView + from krux.wallet import Wallet + from krux.input import BUTTON_ENTER, BUTTON_PAGE + + case = [ + Wallet(tdata.SINGLESIG_24_WORD_KEY), + None, + [ + *([BUTTON_PAGE] * 2), # Go to "Other Formats" + BUTTON_ENTER, # Select "Other Formats" + *([BUTTON_PAGE] * 2), # Go to "Open Stackbit" + BUTTON_ENTER, # Select "Open Stackbit" + BUTTON_PAGE, # Go to "Vertical" + BUTTON_ENTER, # Select "Vertical" + *([BUTTON_ENTER] * 4), # Advance 4 pages + BUTTON_PAGE, # Go to "Back" in Stackbit submenu + BUTTON_ENTER, # Select "Back" from Stackbit submenu + *([BUTTON_PAGE] * 2), # Go to "Back" in Other Formats + BUTTON_ENTER, # Select "Back" from Other Formats + BUTTON_PAGE, # Go to "Back" in mnemonic menu + BUTTON_ENTER, # Select "Back" + ], + ] + ctx = create_ctx(mocker, case[2], case[0], case[1]) + mnemonics = MnemonicsView(ctx) + mocker.spy(mnemonics, "stackbit") + mocker.spy(mnemonics, "_stackbit_vertical") + mocker.spy(mnemonics, "_stackbit_vertical_compact") + mnemonics.mnemonic() + mnemonics.stackbit.assert_called_once() + mnemonics._stackbit_vertical.assert_called_once() + mnemonics._stackbit_vertical_compact.assert_called_once() assert ctx.input.wait_for_button.call_count == len(case[2]) From 8a3cc6914d152e25169b314f7a12327ac73fd1b3 Mon Sep 17 00:00:00 2001 From: bitcoisas Date: Thu, 16 Apr 2026 12:24:53 -0300 Subject: [PATCH 3/5] feat(i18n): add Standard and Vertical strings for stackbit menu --- i18n/translations/de-DE.json | 2 ++ i18n/translations/es-MX.json | 2 ++ i18n/translations/fr-FR.json | 2 ++ i18n/translations/ja-JP.json | 2 ++ i18n/translations/ko-KR.json | 2 ++ i18n/translations/nl-NL.json | 2 ++ i18n/translations/pt-BR.json | 2 ++ i18n/translations/ru-RU.json | 2 ++ i18n/translations/tr-TR.json | 2 ++ i18n/translations/vi-VN.json | 2 ++ i18n/translations/zh-CN.json | 2 ++ 11 files changed, 22 insertions(+) diff --git a/i18n/translations/de-DE.json b/i18n/translations/de-DE.json index 9efac85e2..7cd136945 100644 --- a/i18n/translations/de-DE.json +++ b/i18n/translations/de-DE.json @@ -283,6 +283,7 @@ "Some nodes are not hardened:": "Einige Knoten sind nicht gehärtet:", "Spend (%d):": "Ausgabe (%d):", "Spend:": "Ausgaben:", + "Standard": "Standard", "Standard mode": "Standardmodus", "Static": "Statisch", "Stats for Nerds": "Statistiken für Nerds", @@ -330,6 +331,7 @@ "Value %s out of range: [%s, %s]": "Wert %S außerhalb des Bereichs: [ %s, %s]", "Verifying…": "Überprüfung…", "Version": "Version", + "Vertical": "Vertikal", "Via Camera": "Via Kamera", "Via D20": "Via D20", "Via D6": "Via D6", diff --git a/i18n/translations/es-MX.json b/i18n/translations/es-MX.json index ad181ab6a..b353822a1 100644 --- a/i18n/translations/es-MX.json +++ b/i18n/translations/es-MX.json @@ -283,6 +283,7 @@ "Some nodes are not hardened:": "Algunos nodos no están endurecidos:", "Spend (%d):": "Gastos (%d):", "Spend:": "Gasto:", + "Standard": "Estándar", "Standard mode": "Modo estándar", "Static": "Estático", "Stats for Nerds": "Estadísticas para Entendidos", @@ -330,6 +331,7 @@ "Value %s out of range: [%s, %s]": "Valor %s fuera del rango: [ %s, %s]", "Verifying…": "Verificando…", "Version": "Versión", + "Vertical": "Vertical", "Via Camera": "Desde Cámara", "Via D20": "Vía D20", "Via D6": "Vía D6", diff --git a/i18n/translations/fr-FR.json b/i18n/translations/fr-FR.json index e9be61249..bcc436ba9 100644 --- a/i18n/translations/fr-FR.json +++ b/i18n/translations/fr-FR.json @@ -283,6 +283,7 @@ "Some nodes are not hardened:": "Certains nœuds ne sont pas durcis :", "Spend (%d):": "Dépense (%d) :", "Spend:": "Dépense :", + "Standard": "Standard", "Standard mode": "Mode standard", "Static": "Statique", "Stats for Nerds": "Statistiques pour les geeks", @@ -330,6 +331,7 @@ "Value %s out of range: [%s, %s]": "Valeur %s hors de portée: [%s, %s]", "Verifying…": "Vérification…", "Version": "Version", + "Vertical": "Vertical", "Via Camera": "Par caméra", "Via D20": "Via D20", "Via D6": "Via D6", diff --git a/i18n/translations/ja-JP.json b/i18n/translations/ja-JP.json index a2dfc2969..af4418e26 100644 --- a/i18n/translations/ja-JP.json +++ b/i18n/translations/ja-JP.json @@ -283,6 +283,7 @@ "Some nodes are not hardened:": "一部のノードは硬化されていません:", "Spend (%d):": "支出(%d):", "Spend:": "支出:", + "Standard": "標準", "Standard mode": "標準モード", "Static": "静止画", "Stats for Nerds": "オタクのための統計", @@ -330,6 +331,7 @@ "Value %s out of range: [%s, %s]": "値%sが範囲外です: [ %s, %s]", "Verifying…": "認証中…", "Version": "バージョン", + "Vertical": "縦向き", "Via Camera": "カメラ経由", "Via D20": "D20経由", "Via D6": "D6経由", diff --git a/i18n/translations/ko-KR.json b/i18n/translations/ko-KR.json index 0124099b0..06b1db29f 100644 --- a/i18n/translations/ko-KR.json +++ b/i18n/translations/ko-KR.json @@ -283,6 +283,7 @@ "Some nodes are not hardened:": "일부 노드가 경화되지 않습니다:", "Spend (%d):": "Spend (%d):", "Spend:": "지출:", + "Standard": "표준", "Standard mode": "표준 모드", "Static": "Static", "Stats for Nerds": "전문가를 위한 통계", @@ -330,6 +331,7 @@ "Value %s out of range: [%s, %s]": "%s는 [%s, %s] 범위를 벗어났습니다", "Verifying…": "확인…", "Version": "버전", + "Vertical": "세로", "Via Camera": "카메라", "Via D20": "20면체 주사위", "Via D6": "일반 주사위", diff --git a/i18n/translations/nl-NL.json b/i18n/translations/nl-NL.json index 0595c0435..6f889dc1a 100644 --- a/i18n/translations/nl-NL.json +++ b/i18n/translations/nl-NL.json @@ -283,6 +283,7 @@ "Some nodes are not hardened:": "Sommige knooppunten zijn niet gehard:", "Spend (%d):": "Uitgaven (%d):", "Spend:": "Uitgaven:", + "Standard": "Standaard", "Standard mode": "Standaardmodus", "Static": "Statisch", "Stats for Nerds": "Statistieken voor nerds", @@ -330,6 +331,7 @@ "Value %s out of range: [%s, %s]": "Waarde %s is buiten bereik: [%s, %s]", "Verifying…": "Controleren…", "Version": "Versie", + "Vertical": "Verticaal", "Via Camera": "Via camera", "Via D20": "Via D20", "Via D6": "Via D6", diff --git a/i18n/translations/pt-BR.json b/i18n/translations/pt-BR.json index e506f1ee2..4c36089d9 100644 --- a/i18n/translations/pt-BR.json +++ b/i18n/translations/pt-BR.json @@ -283,6 +283,7 @@ "Some nodes are not hardened:": "Alguns nós não são hardened:", "Spend (%d):": "Gastos (%d):", "Spend:": "Gasto:", + "Standard": "Padrão", "Standard mode": "Modo padrão", "Static": "Estático", "Stats for Nerds": "Estatísticas para nerds", @@ -330,6 +331,7 @@ "Value %s out of range: [%s, %s]": "Valor %s fora do intervalo: [%s, %s]", "Verifying…": "Checando…", "Version": "Versão", + "Vertical": "Vertical", "Via Camera": "Pela Câmera", "Via D20": "Via D20", "Via D6": "Via D6", diff --git a/i18n/translations/ru-RU.json b/i18n/translations/ru-RU.json index 4b47d0dd9..278020ad9 100644 --- a/i18n/translations/ru-RU.json +++ b/i18n/translations/ru-RU.json @@ -283,6 +283,7 @@ "Some nodes are not hardened:": "Некоторые узлы не укреплены:", "Spend (%d):": "Расход (%d):", "Spend:": "Расход:", + "Standard": "Стандартный", "Standard mode": "Стандартный режим", "Static": "Static / Статическое оборудование", "Stats for Nerds": "Статистика для Гиков", @@ -330,6 +331,7 @@ "Value %s out of range: [%s, %s]": "Значение %s вне диапозона: [%s, %s]", "Verifying…": "Верификация…", "Version": "Версия", + "Vertical": "Вертикальный", "Via Camera": "С Помощью Камеры", "Via D20": "С Помощью D20", "Via D6": "С Помощью D6", diff --git a/i18n/translations/tr-TR.json b/i18n/translations/tr-TR.json index 97747a70a..34614a41c 100644 --- a/i18n/translations/tr-TR.json +++ b/i18n/translations/tr-TR.json @@ -283,6 +283,7 @@ "Some nodes are not hardened:": "Bazı düğümler sertleştirilmemiş:", "Spend (%d):": "Harcama (%d):", "Spend:": "Harcama:", + "Standard": "Standart", "Standard mode": "Standart Mod", "Static": "Statik", "Stats for Nerds": "İnekler İçin İstatistikler", @@ -330,6 +331,7 @@ "Value %s out of range: [%s, %s]": "%s değeri aralık dışında: [%s, %s]", "Verifying…": "Doğrulanıyor…", "Version": "Sürüm", + "Vertical": "Dikey", "Via Camera": "Kamera Aracılığıyla", "Via D20": "D20 Aracılığıyla", "Via D6": "D6 Aracılığıyla", diff --git a/i18n/translations/vi-VN.json b/i18n/translations/vi-VN.json index d901f57c9..c44e0be5e 100644 --- a/i18n/translations/vi-VN.json +++ b/i18n/translations/vi-VN.json @@ -283,6 +283,7 @@ "Some nodes are not hardened:": "Một số nút không được làm cứng:", "Spend (%d):": "Chi tiêu (%d):", "Spend:": "Chi tiêu:", + "Standard": "Tiêu chuẩn", "Standard mode": "Chế độ Tiêu chuẩn", "Static": "Tĩnh", "Stats for Nerds": "Số liệu thống kê cho Mọt sách", @@ -330,6 +331,7 @@ "Value %s out of range: [%s, %s]": "Giá trị %s ngoài phạm vi: [ %s, %s]", "Verifying…": "Xác minh…", "Version": "Phiên Bản", + "Vertical": "Dọc", "Via Camera": "Qua máy ảnh", "Via D20": "Qua xúc xắc 20 mặt", "Via D6": "Qua xúc xắc 6 mặt", diff --git a/i18n/translations/zh-CN.json b/i18n/translations/zh-CN.json index e6deb371e..c3bc440b1 100644 --- a/i18n/translations/zh-CN.json +++ b/i18n/translations/zh-CN.json @@ -283,6 +283,7 @@ "Some nodes are not hardened:": "有些节点未硬化:", "Spend (%d):": "花费 (%d):", "Spend:": "花费", + "Standard": "标准", "Standard mode": "标准模式", "Static": "Static 静态?", "Stats for Nerds": "极客统计数据", @@ -329,6 +330,7 @@ "User's Data": "用户数据", "Value %s out of range: [%s, %s]": "值 %s 超出范围:[ %s,%s ]", "Verifying…": "验证中…", + "Vertical": "垂直", "Version": "版本", "Via Camera": "通过摄像头", "Via D20": "通过 D20", From 89112a21d2d9444feff4de15aa7204c24ddfba5b Mon Sep 17 00:00:00 2001 From: bitcoisas Date: Tue, 5 May 2026 01:32:32 -0300 Subject: [PATCH 4/5] feat: add vertical input for Stackbit 1248 --- src/krux/pages/mnemonic_loader.py | 25 +++ src/krux/pages/stack_1248.py | 306 ++++++++++++++++++++++++++++++ 2 files changed, 331 insertions(+) diff --git a/src/krux/pages/mnemonic_loader.py b/src/krux/pages/mnemonic_loader.py index 366b5af9d..025df86e9 100644 --- a/src/krux/pages/mnemonic_loader.py +++ b/src/krux/pages/mnemonic_loader.py @@ -257,6 +257,20 @@ def possible_letters(prefix): def load_key_from_1248(self): """Menu handler to load key from Stackbit 1248 sheet metal storage method""" + submenu = Menu( + self.ctx, + [ + (t("Standard"), self._load_key_from_1248_standard), + (t("Vertical"), self._load_key_from_1248_vertical), + ], + ) + index, status = submenu.run_loop() + if index == submenu.back_index: + return MENU_CONTINUE + return status + + def _load_key_from_1248_standard(self): + """Load key from horizontal Stackbit 1248 layout""" from .stack_1248 import Stackbit stackbit = Stackbit(self.ctx) @@ -266,6 +280,17 @@ def load_key_from_1248(self): return self._load_key_from_words(words) return MENU_CONTINUE + def _load_key_from_1248_vertical(self): + """Load key from vertical Stackbit 1248 layout""" + from .stack_1248 import Stackbit + + stackbit = Stackbit(self.ctx) + words = stackbit.enter_1248_vertical() + del stackbit + if words is not None: + return self._load_key_from_words(words) + return MENU_CONTINUE + def load_key_from_tiny_seed(self): """Menu handler to manually load key from Tinyseed sheet metal storage method""" from .tiny_seed import TinySeed diff --git a/src/krux/pages/stack_1248.py b/src/krux/pages/stack_1248.py index a86b17b63..9f671c5aa 100644 --- a/src/krux/pages/stack_1248.py +++ b/src/krux/pages/stack_1248.py @@ -41,6 +41,16 @@ BIT_WEIGHTS = (1, 2, 4, 8) BIT_LABELS = ("1", "2", "4", "8") +# Vertical 1248 input grid dimensions +VERT_N_DIGITS = 4 # columns — one per BIP39 digit +VERT_N_BITS = 4 # rows — one per bit weight (1, 2, 4, 8) +VERT_N_CELLS = VERT_N_DIGITS * VERT_N_BITS # 16 selectable grid cells +VERT_ESC_INDEX = VERT_N_CELLS # 16 — first touch index of Esc button +VERT_GO_INDEX = VERT_N_CELLS + 2 # 18 — first touch index of Go button +# The first digit of a BIP39 index is at most 2; bit weights 4 and 8 are +# therefore unreachable for column 0. Those cells are shaded and skipped. +VERT_INVALID_CELLS = frozenset({VERT_N_DIGITS * 2, VERT_N_DIGITS * 3}) # {8, 12} + class Stackbit(Page): """Class for handling Stackbit 1248 fomat""" @@ -714,3 +724,299 @@ def enter_1248(self): if len(words) in (12, 24): return words return None + + # ------------------------------------------------------------------ + # Vertical 1248 input — 4 × 4 grid (digits × bit-weights) + # ------------------------------------------------------------------ + + def _layout_vertical(self): + """Return geometry constants for the vertical input grid. + + All positions are derived from two base measures so the layout + scales cleanly across Amigo (320×480), Dock (240×320) and + M5StickV (135×240). + """ + pad = DEFAULT_PADDING + label_w = ( + FONT_WIDTH + DEFAULT_PADDING + ) # breathing room between border and labels + gap = FONT_HEIGHT // 2 + + # Cell size: widest square that fits the available width, capped at + # 2 × FONT_HEIGHT so cells don't become oversized on tall screens. + available_w = self.ctx.display.width() - 2 * pad - label_w + cell_w = available_w // VERT_N_DIGITS + max_cell_h = (self.ctx.display.height() - 4 * FONT_HEIGHT - pad - gap) // ( + VERT_N_BITS + 1 + ) + cell_size = min(cell_w, max(max_cell_h, FONT_HEIGHT), 2 * FONT_HEIGHT) + + grid_w = VERT_N_DIGITS * cell_size + grid_h = VERT_N_BITS * cell_size + + # Center the full block (labels + grid) horizontally on the display + total_w = label_w + grid_w + x_label = max(pad, (self.ctx.display.width() - total_w) // 2) + x_grid = x_label + label_w + + # Layout order (top → bottom): + # title line · word-number badge · grid · gap · preview line · menu row + # Single preview line mirrors the standard horizontal mode style: + # "1244: opinion" with highlight_prefix=":" coloring the word. + total_h = 2 * FONT_HEIGHT + grid_h + gap + FONT_HEIGHT + cell_size + y_start = max(pad, (self.ctx.display.height() - total_h) // 2) + y_grid = y_start + 2 * FONT_HEIGHT + y_preview = y_grid + grid_h + gap + y_menu = y_preview + FONT_HEIGHT + + return ( + x_grid, + y_grid, + y_start, + y_menu, + cell_size, + cell_size, + grid_w, + x_label, + y_preview, + ) + + def _map_keys_array_vertical(self, x_grid, y_grid, cell_w, cell_h, y_menu): + """Set touch regions for the 4 × 4 grid plus the Esc / Go menu row.""" + if not kboard.has_touchscreen: + return + self.ctx.input.touch.clear_regions() + x = x_grid + for _ in range(VERT_N_DIGITS + 1): + self.ctx.input.touch.x_regions.append(x) + x += cell_w + y = y_grid + for _ in range(VERT_N_BITS): + self.ctx.input.touch.y_regions.append(y) + y += cell_h + self.ctx.input.touch.y_regions.append(y_menu) + self.ctx.input.touch.y_regions.append(y_menu + cell_h) + + def _draw_grid_vertical(self, x_grid, y_grid, cell_w, cell_h, grid_w): + """Draw the 4 × 4 cell borders, shading invalid cells in column 0.""" + grid_h = VERT_N_BITS * cell_h + # Outer border + self.ctx.display.draw_line( + x_grid, y_grid, x_grid + grid_w, y_grid, theme.frame_color + ) + self.ctx.display.draw_line( + x_grid, y_grid + grid_h, x_grid + grid_w, y_grid + grid_h, theme.frame_color + ) + self.ctx.display.draw_line( + x_grid, y_grid, x_grid, y_grid + grid_h, theme.frame_color + ) + self.ctx.display.draw_line( + x_grid + grid_w, y_grid, x_grid + grid_w, y_grid + grid_h, theme.frame_color + ) + # Internal column dividers + for col in range(1, VERT_N_DIGITS): + x = x_grid + col * cell_w + self.ctx.display.draw_line(x, y_grid, x, y_grid + grid_h, theme.frame_color) + # Internal row dividers + for row in range(1, VERT_N_BITS): + y = y_grid + row * cell_h + self.ctx.display.draw_line(x_grid, y, x_grid + grid_w, y, theme.frame_color) + # Shade cells that are structurally unreachable (first digit, rows 2-3) + for row in range(2, VERT_N_BITS): + self.ctx.display.fill_rectangle( + x_grid, y_grid + row * cell_h, cell_w, cell_h, theme.disabled_color + ) + + def _draw_punched_vertical(self, digits, x_grid, y_grid, cell_w, cell_h): + """Draw filled squares for every bit that is set in the current digits.""" + dot_size = max(min(cell_w, cell_h) * 2 // 3, 1) + radius = dot_size // 2 + for col, digit in enumerate(digits): + x_col = x_grid + col * cell_w + for row, bit_val in enumerate(BIT_WEIGHTS): + if digit & bit_val: + x_dot = x_col + (cell_w - dot_size) // 2 + y_dot = y_grid + row * cell_h + (cell_h - dot_size) // 2 + self.ctx.display.fill_rectangle( + x_dot, y_dot, dot_size, dot_size, theme.highlight_color, radius + ) + + def _draw_menu_vertical(self, x_grid, y_menu, grid_w, cell_h): + """Draw the Esc and Go buttons below the grid.""" + label_y = y_menu + (cell_h - FONT_HEIGHT) // 2 + half_w = grid_w // 2 + esc_label = t("Esc") + go_label = t("Go") + esc_x = x_grid + (half_w - len(esc_label) * FONT_WIDTH) // 2 + go_x = x_grid + half_w + (half_w - len(go_label) * FONT_WIDTH) // 2 + self.ctx.display.draw_string(esc_x, label_y, esc_label, theme.no_esc_color) + self.ctx.display.draw_string(go_x, label_y, go_label, theme.go_color) + if kboard.has_touchscreen: + mid = x_grid + half_w + self.ctx.display.draw_line( + x_grid, y_menu, x_grid + grid_w, y_menu, theme.frame_color + ) + self.ctx.display.draw_line( + x_grid, + y_menu + cell_h, + x_grid + grid_w, + y_menu + cell_h, + theme.frame_color, + ) + self.ctx.display.draw_line( + x_grid, y_menu, x_grid, y_menu + cell_h, theme.frame_color + ) + self.ctx.display.draw_line( + mid, y_menu, mid, y_menu + cell_h, theme.frame_color + ) + self.ctx.display.draw_line( + x_grid + grid_w, + y_menu, + x_grid + grid_w, + y_menu + cell_h, + theme.frame_color, + ) + + def _draw_index_vertical( + self, index, x_grid, y_grid, cell_w, cell_h, y_menu, grid_w + ): + """Outline the currently focused cell or menu button.""" + if index >= VERT_GO_INDEX: + x = x_grid + grid_w // 2 + self.ctx.display.outline(x, y_menu, grid_w // 2, cell_h, theme.go_color) + elif index >= VERT_ESC_INDEX: + self.ctx.display.outline( + x_grid, y_menu, grid_w // 2, cell_h, theme.no_esc_color + ) + else: + col = index % VERT_N_DIGITS + row = index // VERT_N_DIGITS + self.ctx.display.outline( + x_grid + col * cell_w, + y_grid + row * cell_h, + cell_w, + cell_h, + theme.fg_color, + ) + + def _toggle_bit_vertical(self, digits, index): + """Toggle the bit addressed by *index*, enforcing first-digit constraints. + + The first BIP39 digit is 0-2, so bit weights 4 (row 2) and 8 (row 3) + are invalid for column 0 and are silently ignored. + """ + if index in VERT_INVALID_CELLS: + return digits + col = index % VERT_N_DIGITS + row = index // VERT_N_DIGITS + bit_val = BIT_WEIGHTS[row] + new_val = digits[col] ^ bit_val + if col == 0 and new_val > 2: + # Clamp: keep only the toggled bit, drop the rest + new_val = bit_val if digits[col] == 0 else 0 + elif col != 0 and new_val > 9: + new_val = bit_val if digits[col] == 0 else 0 + digits[col] = new_val + return digits + + def _index_vertical(self, index, btn): + """Advance or rewind the focused cell index, skipping invalid cells.""" + if btn in (BUTTON_PAGE, FAST_FORWARD): + index = 0 if index >= VERT_GO_INDEX else index + 1 + if index in VERT_INVALID_CELLS: + index += 1 + elif btn in (BUTTON_PAGE_PREV, FAST_BACKWARD): + index = VERT_GO_INDEX if index <= 0 else index - 1 + if index in VERT_INVALID_CELLS: + index -= 1 + return index + + def enter_1248_vertical(self): + """UI to manually enter a seed from a vertical Stackbit 1248 card.""" + x_grid, y_grid, y_start, y_menu, cell_w, cell_h, grid_w, x_label, y_preview = ( + self._layout_vertical() + ) + index = 0 + digits = [0, 0, 0, 0] + word_index = 1 + words = [] + while word_index <= 24: + self._map_keys_array_vertical(x_grid, y_grid, cell_w, cell_h, y_menu) + self.ctx.display.draw_hcentered_text("Stackbit 1248", y_start) + self.ctx.display.fill_rectangle( + x_grid, y_start + FONT_HEIGHT, grid_w, FONT_HEIGHT, theme.disabled_color + ) + self.ctx.display.draw_string( + x_grid + (grid_w - 2 * FONT_WIDTH) // 2, + y_start + FONT_HEIGHT, + "%02d" % word_index, + theme.fg_color, + theme.disabled_color, + ) + for i, label in enumerate(BIT_LABELS): + self.ctx.display.draw_string( + x_label, y_grid + i * cell_h, label, theme.fg_color + ) + self._draw_grid_vertical(x_grid, y_grid, cell_w, cell_h, grid_w) + self._draw_menu_vertical(x_grid, y_menu, grid_w, cell_h) + if self.ctx.input.buttons_active: + self._draw_index_vertical( + index, x_grid, y_grid, cell_w, cell_h, y_menu, grid_w + ) + digits_str = "".join(str(d) for d in digits) + word = self.digits_to_word(digits) + if word is not None: + preview_str = digits_str + ": " + word + color = theme.fg_color + else: + preview_str = digits_str + color = theme.error_color + self.ctx.display.draw_hcentered_text( + preview_str, y_preview, color=color, highlight_prefix=":" + ) + self._draw_punched_vertical(digits, x_grid, y_grid, cell_w, cell_h) + btn = self.ctx.input.wait_for_fastnav_button() + if btn == BUTTON_TOUCH: + btn = BUTTON_ENTER + index = self.ctx.input.touch.current_index() + if btn == BUTTON_ENTER: + if index >= VERT_GO_INDEX: + word = self.digits_to_word(digits) + if word is not None: + prompt_str = ( + str(word_index) + + ".\n\n" + + "".join(str(d) for d in digits) + + ": " + + str(word) + + "\n\n" + ) + digits = [0, 0, 0, 0] + index = 0 + self.ctx.display.clear() + if self.prompt( + prompt_str, + self.ctx.display.height() // 2, + highlight_prefix=":", + ): + words.append(word) + else: + self.ctx.display.clear() + continue + if word_index == 12: + self.ctx.display.clear() + if self.prompt(t("Done?"), self.ctx.display.height() // 2): + break + word_index += 1 + elif index >= VERT_ESC_INDEX: + self.ctx.display.clear() + if self.prompt(t("Are you sure?"), self.ctx.display.height() // 2): + break + else: + digits = self._toggle_bit_vertical(digits, index) + else: + index = self._index_vertical(index, btn) + self.ctx.display.clear() + if len(words) in (12, 24): + return words + return None From 690856caeb65f0ae8e2a0702b1097211266e6e01 Mon Sep 17 00:00:00 2001 From: bitcoisas Date: Tue, 5 May 2026 01:43:49 -0300 Subject: [PATCH 5/5] test: add tests for Stackbit 1248 vertical input --- tests/pages/test_login.py | 3 +- tests/pages/test_stackbit.py | 120 ++++++++++++++++++++++++++++++++--- 2 files changed, 113 insertions(+), 10 deletions(-) diff --git a/tests/pages/test_login.py b/tests/pages/test_login.py index 2023fbab3..7782e9162 100644 --- a/tests/pages/test_login.py +++ b/tests/pages/test_login.py @@ -1396,7 +1396,8 @@ def test_load_12w_from_1248(m5stickv, mocker, mocker_printer): from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV BTN_SEQUENCE = ( - ( + [BUTTON_ENTER] # Select "Standard" from Standard/Vertical submenu + + ( [BUTTON_ENTER] # 1 press select first column num 1 + [BUTTON_PAGE_PREV] # 1 press to change to "Go" + [BUTTON_ENTER] # 1 press to select Go diff --git a/tests/pages/test_stackbit.py b/tests/pages/test_stackbit.py index 7be313213..bb510ba63 100644 --- a/tests/pages/test_stackbit.py +++ b/tests/pages/test_stackbit.py @@ -72,11 +72,7 @@ def test_export_mnemonic_stackbit_standard_amigo(mocker, amigo, tdata): def test_export_mnemonic_stackbit_vertical(mocker, amigo, tdata): - """Grouped layout on Amigo: 2 words/group, 4 words/page, 6 pages for 24-word mnemonic. - - Amigo uses FONT_WIDTH=12, so 3 words/group would overflow the word name text. - The layout auto-selects 2 words/group → 4 words/page → 6 pages. - """ + """Grouped layout on Amigo: 2 words/group, 4 words/page, 6 pages for 24-word mnemonic.""" from krux.pages.home_pages.mnemonic_backup import MnemonicsView from krux.wallet import Wallet from krux.input import BUTTON_ENTER, BUTTON_PAGE @@ -113,10 +109,7 @@ def test_export_mnemonic_stackbit_vertical(mocker, amigo, tdata): def test_export_mnemonic_stackbit_vertical_compact(mocker, m5stickv, tdata): - """Dense layout on M5StickV: 6 words per page, 4 pages for 24-word mnemonic. - - 2 words side-by-side × 3 rows, no word names or BIP39 codes. - """ + """Dense layout on M5StickV: 6 words per page, 4 pages for 24-word mnemonic.""" from krux.pages.home_pages.mnemonic_backup import MnemonicsView from krux.wallet import Wallet from krux.input import BUTTON_ENTER, BUTTON_PAGE @@ -236,6 +229,115 @@ def test_esc_entering_stackbit(amigo, mocker): assert words == None +def test_load_key_from_1248_standard(m5stickv, mocker): + """Selecting Standard in the 1248 submenu calls _load_key_from_1248_standard.""" + from krux.pages.mnemonic_loader import MnemonicLoader + from krux.pages import MENU_CONTINUE + from krux.input import BUTTON_ENTER, BUTTON_PAGE + + BTN_SEQUENCE = [ + BUTTON_ENTER, # Select "Standard" + # _load_key_from_1248_standard is patched, returns immediately + *([BUTTON_PAGE] * 2), # Go to "Back" in submenu + BUTTON_ENTER, # Select "Back" + ] + ctx = create_ctx(mocker, BTN_SEQUENCE) + loader = MnemonicLoader(ctx) + mocker.patch.object( + loader, "_load_key_from_1248_standard", return_value=MENU_CONTINUE + ) + mocker.spy(loader, "_load_key_from_1248_vertical") + loader.load_key_from_1248() + + loader._load_key_from_1248_standard.assert_called_once() + loader._load_key_from_1248_vertical.assert_not_called() + assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE) + + +def test_load_key_from_1248_vertical(m5stickv, mocker): + """Selecting Vertical in the 1248 submenu calls _load_key_from_1248_vertical.""" + from krux.pages.mnemonic_loader import MnemonicLoader + from krux.pages import MENU_CONTINUE + from krux.input import BUTTON_ENTER, BUTTON_PAGE + + BTN_SEQUENCE = [ + BUTTON_PAGE, # Go to "Vertical" + BUTTON_ENTER, # Select "Vertical" + # _load_key_from_1248_vertical is patched, returns immediately + BUTTON_PAGE, # Go to "Back" in submenu + BUTTON_ENTER, # Select "Back" + ] + ctx = create_ctx(mocker, BTN_SEQUENCE) + loader = MnemonicLoader(ctx) + mocker.patch.object( + loader, "_load_key_from_1248_vertical", return_value=MENU_CONTINUE + ) + mocker.spy(loader, "_load_key_from_1248_standard") + loader.load_key_from_1248() + + loader._load_key_from_1248_vertical.assert_called_once() + loader._load_key_from_1248_standard.assert_not_called() + assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE) + + +def test_enter_stackbit_vertical(m5stickv, mocker): + """Button navigation: enter 12 'language' words on a vertical 1248 grid.""" + from krux.pages.stack_1248 import Stackbit + from krux.input import BUTTON_ENTER, BUTTON_PAGE_PREV + + BTN_SEQUENCE = ( + # Toggle bit-weight 1, jump to Go, confirm word + [BUTTON_ENTER, BUTTON_PAGE_PREV, BUTTON_ENTER, BUTTON_ENTER] * 11 + # 12th word: same plus confirm "Done?" + + [BUTTON_ENTER, BUTTON_PAGE_PREV, BUTTON_ENTER, BUTTON_ENTER, BUTTON_ENTER] + ) + TEST_12_WORDS = "language language language language language language language language language language language language" + + ctx = create_ctx(mocker, BTN_SEQUENCE) + stackbit = Stackbit(ctx) + words = stackbit.enter_1248_vertical() + + assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE) + assert " ".join(words) == TEST_12_WORDS + + +def test_enter_stackbit_vertical_touch(amigo, mocker): + """Touch navigation: enter 12 'language' words via touch screen.""" + from krux.pages.stack_1248 import Stackbit, VERT_GO_INDEX + from krux.input import BUTTON_TOUCH + + YES = 1 + BTN_SEQUENCE = [BUTTON_TOUCH] * 3 * 12 + [BUTTON_TOUCH] + TOUCH_SEQUENCE = [0, VERT_GO_INDEX, YES] * 12 + [YES] + TEST_12_WORDS = "language language language language language language language language language language language language" + + ctx = create_ctx(mocker, BTN_SEQUENCE, touch_seq=TOUCH_SEQUENCE) + stackbit = Stackbit(ctx) + words = stackbit.enter_1248_vertical() + + assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE) + assert " ".join(words) == TEST_12_WORDS + + +def test_esc_entering_stackbit_vertical(amigo, mocker): + """Pressing Esc from the vertical input grid returns None.""" + from krux.pages.stack_1248 import Stackbit + from krux.input import BUTTON_ENTER, BUTTON_PAGE_PREV + + BTN_SEQUENCE = ( + # Move to Esc + [BUTTON_PAGE_PREV] * 3 + + [BUTTON_ENTER] # Select "Esc" + + [BUTTON_ENTER] # Confirm + ) + ctx = create_ctx(mocker, BTN_SEQUENCE) + stackbit = Stackbit(ctx) + words = stackbit.enter_1248_vertical() + + assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE) + assert words is None + + def test_entering_stackbit_buttons_turbo(mocker, m5stickv): from krux.pages.stack_1248 import Stackbit from krux.input import PRESSED, FAST_FORWARD, FAST_BACKWARD, Input