diff --git a/CHANGELOG.md b/CHANGELOG.md
index 241f25d7c..c06c5e3ed 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,8 @@
# Changelog 26.05.0 - May 2025
+### Stackbit 1248 Vertical Layout
+Added vertical layout option for Stackbit 1248 backup display, allowing users to choose between Standard (horizontal) and Vertical (transposed) grid orientations.
+
### Migrate UR encoding to uUR MicroPython C module
Switch from the pure-Python urtypes and foundation-ur-py packages to the new uUR C module, allowing faster UR QR codes decoding with a smaller RAM footprint.
diff --git a/docs/getting-started/usage/navigating-the-main-menu.en.md b/docs/getting-started/usage/navigating-the-main-menu.en.md
index 3857fbcd9..764cfd6a8 100644
--- a/docs/getting-started/usage/navigating-the-main-menu.en.md
+++ b/docs/getting-started/usage/navigating-the-main-menu.en.md
@@ -96,11 +96,15 @@ Display the BIP39 mnemonic word numbers (1-2048) in decimal, hex, or octal forma
-
This metal backup format represents the BIP39 mnemonic word's numbers (1-2048). Each of the four digits is converted to a sum of 1, 2, 4 or 8. This option does not print even if a printer driver is set.
+
+
+Vertical layout transposes the grid, with rows = weights (1,2,4,8) and columns = digits.
+
- **Tinyseed**
diff --git a/docs/img/maixpy_amigo/backup-stackbit-vertical-300.png b/docs/img/maixpy_amigo/backup-stackbit-vertical-300.png
new file mode 100644
index 000000000..6a69196ca
Binary files /dev/null and b/docs/img/maixpy_amigo/backup-stackbit-vertical-300.png differ
diff --git a/docs/img/maixpy_m5stickv/backup-stackbit-vertical-250.png b/docs/img/maixpy_m5stickv/backup-stackbit-vertical-250.png
new file mode 100644
index 000000000..249b2a57d
Binary files /dev/null and b/docs/img/maixpy_m5stickv/backup-stackbit-vertical-250.png differ
diff --git a/i18n/translations/de-DE.json b/i18n/translations/de-DE.json
index f2194616a..c3783059a 100644
--- a/i18n/translations/de-DE.json
+++ b/i18n/translations/de-DE.json
@@ -284,6 +284,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",
@@ -331,6 +332,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 2784413d1..0e3960ba2 100644
--- a/i18n/translations/es-MX.json
+++ b/i18n/translations/es-MX.json
@@ -284,6 +284,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",
@@ -331,6 +332,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 d761a902f..78fc38eee 100644
--- a/i18n/translations/fr-FR.json
+++ b/i18n/translations/fr-FR.json
@@ -284,6 +284,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",
@@ -331,6 +332,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 facbd1edc..4806accdf 100644
--- a/i18n/translations/ja-JP.json
+++ b/i18n/translations/ja-JP.json
@@ -284,6 +284,7 @@
"Some nodes are not hardened:": "一部のノードは硬化されていません:",
"Spend (%d):": "支出(%d):",
"Spend:": "支出:",
+ "Standard": "標準",
"Standard mode": "標準モード",
"Static": "静止画",
"Stats for Nerds": "オタクのための統計",
@@ -331,6 +332,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 6b197e5b3..6c00a9b97 100644
--- a/i18n/translations/ko-KR.json
+++ b/i18n/translations/ko-KR.json
@@ -284,6 +284,7 @@
"Some nodes are not hardened:": "일부 노드가 경화되지 않습니다:",
"Spend (%d):": "Spend (%d):",
"Spend:": "지출:",
+ "Standard": "표준",
"Standard mode": "표준 모드",
"Static": "Static",
"Stats for Nerds": "전문가를 위한 통계",
@@ -331,6 +332,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 7b9517beb..00ddb338a 100644
--- a/i18n/translations/nl-NL.json
+++ b/i18n/translations/nl-NL.json
@@ -284,6 +284,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",
@@ -331,6 +332,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 38c4f3755..ce6a25e94 100644
--- a/i18n/translations/pt-BR.json
+++ b/i18n/translations/pt-BR.json
@@ -284,6 +284,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",
@@ -331,6 +332,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 cce43232f..61a8901af 100644
--- a/i18n/translations/ru-RU.json
+++ b/i18n/translations/ru-RU.json
@@ -284,6 +284,7 @@
"Some nodes are not hardened:": "Некоторые узлы не укреплены:",
"Spend (%d):": "Расход (%d):",
"Spend:": "Расход:",
+ "Standard": "Стандартный",
"Standard mode": "Стандартный режим",
"Static": "Static / Статическое оборудование",
"Stats for Nerds": "Статистика для Гиков",
@@ -331,6 +332,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 c573f542b..3c9f98b7f 100644
--- a/i18n/translations/tr-TR.json
+++ b/i18n/translations/tr-TR.json
@@ -284,6 +284,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",
@@ -331,6 +332,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 9648090a3..31b7f7cd5 100644
--- a/i18n/translations/vi-VN.json
+++ b/i18n/translations/vi-VN.json
@@ -284,6 +284,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",
@@ -331,6 +332,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 7cad610df..3a2f0d23e 100644
--- a/i18n/translations/zh-CN.json
+++ b/i18n/translations/zh-CN.json
@@ -284,6 +284,7 @@
"Some nodes are not hardened:": "有些节点未硬化:",
"Spend (%d):": "花费 (%d):",
"Spend:": "花费",
+ "Standard": "标准",
"Standard mode": "标准模式",
"Static": "Static 静态?",
"Stats for Nerds": "极客统计数据",
@@ -330,6 +331,7 @@
"User's Data": "用户数据",
"Value %s out of range: [%s, %s]": "值 %s 超出范围:[ %s,%s ]",
"Verifying…": "验证中…",
+ "Vertical": "垂直",
"Version": "版本",
"Via Camera": "通过摄像头",
"Via D20": "通过 D20",
diff --git a/simulator/sequences/home-options.txt b/simulator/sequences/home-options.txt
index 6761ee1b3..4c8f9ecfb 100644
--- a/simulator/sequences/home-options.txt
+++ b/simulator/sequences/home-options.txt
@@ -52,9 +52,23 @@ press BUTTON_A
press BUTTON_B
press BUTTON_A
+screenshot backup-stackbit-menu.png
+
+press BUTTON_B
+press BUTTON_A
+
+screenshot backup-stackbit-vertical.png
+
+x2 press BUTTON_A
+press_amigo_only BUTTON_A
+x2 press BUTTON_B
+press BUTTON_A
+
screenshot backup-stackbit.png
x2 press BUTTON_A
+x2 press BUTTON_B
+press BUTTON_A
press BUTTON_B
press BUTTON_A
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))
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])