diff --git a/scenarios/MockUI/src/MockUI/__init__.py b/scenarios/MockUI/src/MockUI/__init__.py index a3064b770..ed20d3776 100644 --- a/scenarios/MockUI/src/MockUI/__init__.py +++ b/scenarios/MockUI/src/MockUI/__init__.py @@ -1,7 +1,8 @@ # MockUI/__init__.py from .basic import BTN_HEIGHT, BTN_WIDTH, MENU_PCT, PAD_SIZE, SWITCH_HEIGHT, SWITCH_WIDTH, STATUS_BTN_HEIGHT, STATUS_BTN_WIDTH, ONE_LETTER_SYMBOL_WIDTH, TWO_LETTER_SYMBOL_WIDTH, THREE_LETTER_SYMBOL_WIDTH, GREEN, ORANGE, RED -from .basic import MainMenu, LockedMenu, StatusBar, ActionScreen, GenericMenu +from .basic import MainMenu, LockedMenu, DeviceBar, WalletBar, ActionScreen, GenericMenu from .basic import NavigationController +from .tour import UIExplainer, GuidedTour from .helpers import UIState, SpecterState, Wallet @@ -22,8 +23,11 @@ BackupsMenu, SecurityMenu, StorageMenu, + SettingsMenu, ) +from .tour import GuidedTour + __all__ = [ "BTN_HEIGHT", "BTN_WIDTH", "MENU_PCT", @@ -39,7 +43,8 @@ "Wallet", "ActionScreen", "UIState", - "StatusBar", + "DeviceBar", + "WalletBar", "SeedPhraseMenu", "SecurityMenu", "InterfacesMenu", @@ -51,6 +56,9 @@ "LockedMenu", "GenerateSeedMenu", "StorageMenu", + "SettingsMenu", "PassphraseMenu", "NavigationController", + "UIExplainer", + "GuidedTour", ] \ No newline at end of file diff --git a/scenarios/MockUI/src/MockUI/basic/__init__.py b/scenarios/MockUI/src/MockUI/basic/__init__.py index ee09fe38e..a359d61f3 100644 --- a/scenarios/MockUI/src/MockUI/basic/__init__.py +++ b/scenarios/MockUI/src/MockUI/basic/__init__.py @@ -1,21 +1,26 @@ -from .ui_consts import BTN_HEIGHT, BTN_WIDTH, MENU_PCT, PAD_SIZE, SWITCH_HEIGHT, SWITCH_WIDTH, STATUS_BTN_HEIGHT, STATUS_BTN_WIDTH, BTC_ICON_WIDTH, ONE_LETTER_SYMBOL_WIDTH, TWO_LETTER_SYMBOL_WIDTH, THREE_LETTER_SYMBOL_WIDTH, GREEN, ORANGE, RED, GREEN_HEX, ORANGE_HEX, RED_HEX, WHITE_HEX, BLACK_HEX +from .ui_consts import BTN_HEIGHT, BTN_WIDTH, BACK_BTN_HEIGHT, BACK_BTN_WIDTH, MENU_PCT, PAD_SIZE, SWITCH_HEIGHT, SWITCH_WIDTH, STATUS_BTN_HEIGHT, STATUS_BTN_WIDTH, BTC_ICON_WIDTH, ONE_LETTER_SYMBOL_WIDTH, TWO_LETTER_SYMBOL_WIDTH, THREE_LETTER_SYMBOL_WIDTH, GREEN, ORANGE, RED, GREEN_HEX, ORANGE_HEX, RED_HEX, WHITE_HEX, BLACK_HEX, TITLE_PADDING, MODAL_WIDTH_PCT, MODAL_HEIGHT_PCT, EXPLAINER_WIDTH_PCT, EXPLAINER_HEIGHT_PCT, EXPLAINER_OVERLAY_OPA from .main_menu import MainMenu from .locked_menu import LockedMenu -from .status_bar import StatusBar +from .device_bar import DeviceBar +from .wallet_bar import WalletBar from .action_screen import ActionScreen from .menu import GenericMenu +from .modal_overlay import ModalOverlay from .navigation_controller import NavigationController from .symbol_lib import BTC_ICONS -__all__ = ["BTN_HEIGHT", "BTN_WIDTH", +__all__ = ["BTN_HEIGHT", "BTN_WIDTH", "BACK_BTN_HEIGHT", "BACK_BTN_WIDTH", "MENU_PCT", "PAD_SIZE", + "TITLE_PADDING", + "MODAL_WIDTH_PCT", "MODAL_HEIGHT_PCT", + "EXPLAINER_WIDTH_PCT", "EXPLAINER_HEIGHT_PCT", "EXPLAINER_OVERLAY_OPA", "SWITCH_HEIGHT", "SWITCH_WIDTH", "STATUS_BTN_HEIGHT", "STATUS_BTN_WIDTH", "BTC_ICON_WIDTH", "ONE_LETTER_SYMBOL_WIDTH", "TWO_LETTER_SYMBOL_WIDTH", "THREE_LETTER_SYMBOL_WIDTH", "GREEN", "ORANGE", "RED", "GREEN_HEX", "ORANGE_HEX", "RED_HEX", "WHITE_HEX", "BLACK_HEX", - "MainMenu", "LockedMenu", "StatusBar", "ActionScreen", "GenericMenu", "NavigationController", - "BTC_ICONS" + "MainMenu", "LockedMenu", "DeviceBar", "WalletBar", "ActionScreen", "GenericMenu", "ModalOverlay", "NavigationController", + "BTC_ICONS" ] \ No newline at end of file diff --git a/scenarios/MockUI/src/MockUI/basic/action_screen.py b/scenarios/MockUI/src/MockUI/basic/action_screen.py index 6edcbaa30..f90bc9aa2 100644 --- a/scenarios/MockUI/src/MockUI/basic/action_screen.py +++ b/scenarios/MockUI/src/MockUI/basic/action_screen.py @@ -1,5 +1,5 @@ import lvgl as lv -from .ui_consts import BTN_HEIGHT, BTN_WIDTH +from .ui_consts import BTN_HEIGHT, BTN_WIDTH, BACK_BTN_WIDTH, BACK_BTN_HEIGHT from .symbol_lib import BTC_ICONS class ActionScreen(lv.obj): @@ -19,11 +19,15 @@ def __init__(self, title, parent, *args, **kwargs): # Fill parent self.set_width(lv.pct(100)) self.set_height(lv.pct(100)) + # Remove padding from base object to allow full-width content + self.set_style_pad_all(0, 0) + # Remove border + self.set_style_border_width(0, 0) # If ui_state has history, show back button to the left of the title if parent.ui_state and parent.ui_state.history and len(parent.ui_state.history) > 0: self.back_btn = lv.button(self) - self.back_btn.set_size(40, 28) + self.back_btn.set_size(BACK_BTN_HEIGHT, BACK_BTN_WIDTH) self.back_ico = lv.image(self.back_btn) BTC_ICONS.CARET_LEFT.add_to_parent(self.back_ico) self.back_ico.center() diff --git a/scenarios/MockUI/src/MockUI/basic/device_bar.py b/scenarios/MockUI/src/MockUI/basic/device_bar.py new file mode 100644 index 000000000..46134dccd --- /dev/null +++ b/scenarios/MockUI/src/MockUI/basic/device_bar.py @@ -0,0 +1,235 @@ +import lvgl as lv +from ..helpers import Battery +from .ui_consts import BTC_ICON_WIDTH, GREEN_HEX, ORANGE_HEX, RED_HEX, STATUS_BTN_HEIGHT, STATUS_BTN_WIDTH, THREE_LETTER_SYMBOL_WIDTH +from .symbol_lib import BTC_ICONS + + +class DeviceBar(lv.obj): + """Device status bar showing system-level information. Designed to be ~5% of the screen height at the top.""" + + def __init__(self, parent, height_pct=5, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + + self.parent = parent # for callback access + + self.set_width(lv.pct(100)) + self.set_height(lv.pct(height_pct)) + + self.set_layout(lv.LAYOUT.FLEX) + self.set_flex_flow(lv.FLEX_FLOW.ROW) + self.set_flex_align( + lv.FLEX_ALIGN.SPACE_BETWEEN, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER + ) + self.set_style_pad_all(0, 0) + self.set_style_radius(0, 0) + self.set_style_border_width(0, 0) + + # LEFT SECTION: Lock button + self.left_container = lv.obj(self) + self.left_container.set_width(STATUS_BTN_WIDTH + 10) + self.left_container.set_height(lv.pct(100)) + self.left_container.set_layout(lv.LAYOUT.FLEX) + self.left_container.set_flex_flow(lv.FLEX_FLOW.ROW) + self.left_container.set_flex_align(lv.FLEX_ALIGN.START, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER) + self.left_container.set_style_pad_all(0, 0) + self.left_container.set_style_border_width(0, 0) + + self.lock_btn = lv.button(self.left_container) + self.lock_btn.set_size(STATUS_BTN_WIDTH, STATUS_BTN_HEIGHT) + self.lock_ico = lv.image(self.lock_btn) + BTC_ICONS.UNLOCK.add_to_parent(self.lock_ico) + self.lock_ico.center() + self.lock_btn.add_event_cb(self.lock_cb, lv.EVENT.CLICKED, None) + + # CENTER SECTION: Peripheral indicators + self.center_container = lv.obj(self) + self.center_container.set_width(BTC_ICON_WIDTH * 4 + 40) + self.center_container.set_height(lv.pct(100)) + self.center_container.set_layout(lv.LAYOUT.FLEX) + self.center_container.set_flex_flow(lv.FLEX_FLOW.ROW) + self.center_container.set_flex_align(lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER) + self.center_container.set_style_pad_all(0, 0) + self.center_container.set_style_border_width(0, 0) + + # Peripheral indicators (only visible when unlocked) + self.qr_img = lv.image(self.center_container) + self.qr_img.set_width(BTC_ICON_WIDTH) + + self.usb_img = lv.image(self.center_container) + self.usb_img.set_width(BTC_ICON_WIDTH) + + self.sd_img = lv.image(self.center_container) + self.sd_img.set_width(BTC_ICON_WIDTH) + + self.smartcard_img = lv.image(self.center_container) + self.smartcard_img.set_width(BTC_ICON_WIDTH) + + # Make peripheral icons clickable to navigate to interfaces menu + peripheral_icons = [ + self.qr_img, + self.usb_img, + self.sd_img, + self.smartcard_img, + ] + for ico in peripheral_icons: + ico.add_flag(lv.obj.FLAG.CLICKABLE) + ico.add_event_cb(self.peripheral_ico_clicked, lv.EVENT.CLICKED, None) + + # RIGHT SECTION: Battery, Language, Settings, Power (in that order) + self.right_container = lv.obj(self) + self.right_container.set_width(STATUS_BTN_WIDTH * 2 + THREE_LETTER_SYMBOL_WIDTH + 70) + self.right_container.set_height(lv.pct(100)) + self.right_container.set_layout(lv.LAYOUT.FLEX) + self.right_container.set_flex_flow(lv.FLEX_FLOW.ROW) + self.right_container.set_flex_align(lv.FLEX_ALIGN.END, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER) + self.right_container.set_style_pad_all(0, 0) + self.right_container.set_style_border_width(0, 0) + + # Battery icon + self.batt_icon = Battery(self.right_container) + self.batt_icon.VALUE = parent.specter_state.battery_pct + self.batt_icon.update() + + # Language indicator (clickable selector) - always visible + self.lang_lbl = lv.label(self.right_container) + self.lang_lbl.set_text("") + self.lang_lbl.set_width(THREE_LETTER_SYMBOL_WIDTH) + self.lang_lbl.add_flag(lv.obj.FLAG.CLICKABLE) + self.lang_lbl.add_event_cb(self.lang_clicked, lv.EVENT.CLICKED, None) + + # Settings button + self.settings_btn = lv.button(self.right_container) + self.settings_btn.set_size(STATUS_BTN_WIDTH, STATUS_BTN_HEIGHT) + self.settings_ico = lv.image(self.settings_btn) + BTC_ICONS.GEAR.add_to_parent(self.settings_ico) + self.settings_ico.center() + self.settings_btn.add_event_cb(self.settings_cb, lv.EVENT.CLICKED, None) + + # Power button + self.power_btn = lv.button(self.right_container) + self.power_btn.set_size(STATUS_BTN_WIDTH, STATUS_BTN_HEIGHT) + self.power_lbl = lv.label(self.power_btn) + self.power_lbl.set_text(lv.SYMBOL.POWER) + self.power_lbl.center() + self.power_btn.add_event_cb(self.power_cb, lv.EVENT.CLICKED, None) + + # Apply smaller font to labels + self.font = lv.font_montserrat_12 + labels = [self.lang_lbl, self.power_lbl] + for lbl in labels: + lbl.set_style_text_font(self.font, 0) + + def power_cb(self, e): + if e.get_code() == lv.EVENT.CLICKED: + if self.parent.specter_state.battery_pct is None: + self.parent.specter_state.battery_pct = 50 + self.parent.refresh_ui() + else: + self.parent.specter_state.battery_pct = None + self.parent.refresh_ui() + + def lock_cb(self, e): + if e.get_code() == lv.EVENT.CLICKED: + if self.parent.specter_state.is_locked: + # unlocking should be handled by the locked screen's PIN flow + return + else: + # lock the device and force NavigationController to show the locked screen + self.parent.specter_state.lock() + # show_menu will detect is_locked and show the locked screen + self.parent.show_menu(None) + + def peripheral_ico_clicked(self, e): + if e.get_code() == lv.EVENT.CLICKED: + if self.parent.ui_state.current_menu_id != "interfaces": + self.parent.show_menu("interfaces") + + def lang_clicked(self, e): + """Navigate to language selection menu when language label is clicked.""" + if e.get_code() == lv.EVENT.CLICKED: + if self.parent.ui_state.current_menu_id != "select_language": + self.parent.show_menu("select_language") + + def settings_cb(self, e): + """Navigate to settings menu when settings button is clicked.""" + if e.get_code() == lv.EVENT.CLICKED: + if self.parent.ui_state.current_menu_id != "manage_settings": + self.parent.show_menu("manage_settings") + + def refresh(self, state): + """Update visual elements from a SpecterState-like object.""" + locked = state.is_locked + + # Battery (always visible) + self.batt_icon.CHARGING = state.is_charging + if state.has_battery: + perc = state.battery_pct + self.batt_icon.VALUE = perc + self.batt_icon.update() + else: + self.batt_icon.VALUE = 100 + self.batt_icon.update() + + # Language (always visible) + lang_code = self.parent.i18n.get_language() + self.lang_lbl.set_text(self._truncate(lang_code.upper(), 3)) + + # Lock icon (always visible, but changes based on state) + if locked: + BTC_ICONS.LOCK.add_to_parent(self.lock_ico) + # Hide peripheral indicators when locked + self.qr_img.set_src(None) + self.usb_img.set_src(None) + self.sd_img.set_src(None) + self.smartcard_img.set_src(None) + else: + BTC_ICONS.UNLOCK.add_to_parent(self.lock_ico) + # Show peripheral indicators when unlocked + if state.hasQR: + if state.enabledQR: + BTC_ICONS.QR_CODE(GREEN_HEX).add_to_parent(self.qr_img) + else: + BTC_ICONS.QR_CODE(ORANGE_HEX).add_to_parent(self.qr_img) + else: + self.qr_img.set_src(None) + + if state.hasUSB: + if state.enabledUSB: + BTC_ICONS.USB(GREEN_HEX).add_to_parent(self.usb_img) + else: + BTC_ICONS.USB(ORANGE_HEX).add_to_parent(self.usb_img) + else: + self.usb_img.set_src(None) + + if state.hasSD: + if state.enabledSD: + if state.detectedSD: + BTC_ICONS.SD_CARD(GREEN_HEX).add_to_parent(self.sd_img) + else: + BTC_ICONS.SD_CARD(ORANGE_HEX).add_to_parent(self.sd_img) + else: + BTC_ICONS.SD_CARD(RED_HEX).add_to_parent(self.sd_img) + else: + self.sd_img.set_src(None) + + if state.hasSmartCard: + if state.enabledSmartCard: + if state.detectedSmartCard: + BTC_ICONS.SMARTCARD(GREEN_HEX).add_to_parent(self.smartcard_img) + else: + BTC_ICONS.SMARTCARD(ORANGE_HEX).add_to_parent(self.smartcard_img) + else: + BTC_ICONS.SMARTCARD(RED_HEX).add_to_parent(self.smartcard_img) + else: + self.smartcard_img.set_src(None) + + def _truncate(self, text, max_chars): + """Return text truncated to max_chars.""" + if not text: + return "" + s = str(text) + if len(s) <= max_chars: + return s + if max_chars <= 3: + return s[:3] + return s[:max_chars] diff --git a/scenarios/MockUI/src/MockUI/basic/main_menu.py b/scenarios/MockUI/src/MockUI/basic/main_menu.py index 91f273b6e..19249a146 100644 --- a/scenarios/MockUI/src/MockUI/basic/main_menu.py +++ b/scenarios/MockUI/src/MockUI/basic/main_menu.py @@ -19,31 +19,47 @@ def MainMenu(parent, *args, **kwargs): #relevant input possibilities are QR Scanner, SD Card, or (to sign messages) a registered wallet if (state and ((state.hasQR and state.enabledQR) or (state.hasSD and state.enabledSD and state.detectedSD) - or (state.active_wallet and not state.active_wallet.isMultiSig) + or (state and state.active_wallet and not state.active_wallet.isMultiSig and + ( + (state.hasQR and state.enabledQR) + or (state.hasSD and state.enabledSD and state.detectedSD) + or (state.hasUSB and state.enabledUSB) + )) or (state.active_wallet is None and state.hasSmartCard and state.enabledSmartCard and state.detectedSmartCard) )): - menu_items.append((None, t("MAIN_MENU_PROCESS_INPUT"), None, None)) + menu_items.append((None, t("MAIN_MENU_PROCESS_INPUT"), None, None, None, None)) if (state.hasQR and state.enabledQR): - menu_items.append((BTC_ICONS.SCAN, t("MAIN_MENU_SCAN_QR"), "scan_qr", None)) + scan_size = 1 + if not (state.active_wallet is None): + scan_size = 2 + menu_items.append((BTC_ICONS.SCAN, t("MAIN_MENU_SCAN_QR"), "scan_qr", None, scan_size, "HELP_SCAN_QR")) if (state.hasSD and state.enabledSD and state.detectedSD): - menu_items.append((BTC_ICONS.SD_CARD, t("MAIN_MENU_LOAD_SD"), "load_sd", None)) - if (state and state.active_wallet and not state.active_wallet.isMultiSig): - menu_items.append((BTC_ICONS.SIGN, t("MAIN_MENU_SIGN_MESSAGE"), "sign_message", None)) + menu_items.append((BTC_ICONS.SD_CARD, t("MAIN_MENU_LOAD_SD"), "load_sd", None, 2, None)) + if (state and state.active_wallet and not state.active_wallet.isMultiSig and + ( + (state.hasQR and state.enabledQR) + or (state.hasSD and state.enabledSD and state.detectedSD) + or (state.hasUSB and state.enabledUSB) + )): + menu_items.append((BTC_ICONS.SIGN, t("MAIN_MENU_SIGN_MESSAGE"), "sign_message", None, None, None)) if (state and state.active_wallet is None and state.hasSmartCard and state.enabledSmartCard and state.detectedSmartCard): - menu_items.append((BTC_ICONS.SEND, t("MAIN_MENU_IMPORT_SMARTCARD"), "import_from_smartcard", None)) + menu_items.append((BTC_ICONS.SEND, t("MAIN_MENU_IMPORT_SMARTCARD"), "import_from_smartcard", None, 3, None)) - menu_items.append((None, t("MAIN_MENU_CHOOSE_WALLET"), None, None)) - if state.registered_wallets and len(state.registered_wallets) > 0: - menu_items.append((BTC_ICONS.WALLET, t("MAIN_MENU_CHANGE_ADD_WALLET"), "change_wallet", None)) + menu_items.append((None, t("MAIN_MENU_CHOOSE_WALLET"), None, None, None, None)) + if (state and not state.active_wallet is None): + menu_items.append((BTC_ICONS.WALLET, t("MENU_MANAGE_WALLET"), "manage_wallet", None, None, None)) + if state.registered_wallets and len(state.registered_wallets) > 1: + menu_items.append((BTC_ICONS.REFRESH, t("MAIN_MENU_CHANGE_ADD_WALLET"), "change_wallet", None, None, None)) else: - menu_items.append((BTC_ICONS.PLUS, t("MENU_ADD_WALLET"), "add_wallet", None)) + add_size = 2 + if (state.registered_wallets and len(state.registered_wallets) > 0): + add_size = 1 + menu_items.append((BTC_ICONS.PLUS, t("MENU_ADD_WALLET"), "add_wallet", None, add_size, None)) - menu_items.append((None, t("MAIN_MENU_MANAGE_SETTINGS"), None, None)) - if (state and not state.active_wallet is None): - menu_items.append((BTC_ICONS.WALLET, t("MENU_MANAGE_WALLET"), "manage_wallet", None)) - menu_items.append((BTC_ICONS.GEAR, t("MENU_MANAGE_DEVICE"), "manage_device", None)) - menu_items.append((lv.SYMBOL.DRIVE, t("MENU_MANAGE_STORAGE"), "manage_storage", None)) + menu_items.append((None, t("MAIN_MENU_MANAGE_SETTINGS"), None, None, None, None)) + + menu_items.append((BTC_ICONS.GEAR, t("MENU_MANAGE_SETTINGS"), "manage_settings", None, None, None)) return GenericMenu("main", t("MAIN_MENU_TITLE"), menu_items, parent, *args, **kwargs) diff --git a/scenarios/MockUI/src/MockUI/basic/menu.py b/scenarios/MockUI/src/MockUI/basic/menu.py index 0c2cbae18..a15446401 100644 --- a/scenarios/MockUI/src/MockUI/basic/menu.py +++ b/scenarios/MockUI/src/MockUI/basic/menu.py @@ -1,17 +1,21 @@ import lvgl as lv -from .ui_consts import BTN_HEIGHT, BTN_WIDTH, MENU_PCT, PAD_SIZE +from .ui_consts import BACK_BTN_HEIGHT, BACK_BTN_WIDTH, BTN_HEIGHT, BTN_WIDTH, MENU_PCT, MODAL_HEIGHT_PCT, MODAL_WIDTH_PCT, PAD_SIZE, TITLE_PADDING from .symbol_lib import Icon, BTC_ICONS +from .modal_overlay import ModalOverlay class GenericMenu(lv.obj): """Reusable menu builder. title: string title shown at top - menu_items: list of (icon, text, target_behavior, color) where: + menu_items: list of (icon, text, target_behavior, color, size, help_key) where: - icon: Icon object or lv.SYMBOL string - text: Display text for the menu item - target_behavior: None (creates label/spacer), string (menu_id to navigate to), or callable (custom callback) - color: Optional color for the button + - size: Size multiplier for button height (default=1, minimum=1). E.g., size=1.5 increases height by 50% + - help_key: Optional i18n key for help text. If provided, a help icon appears on the right side of the button. + Clicking it shows a popup with the translated help text. """ def __init__(self, menu_id, title, menu_items, parent, *args, **kwargs): @@ -25,6 +29,8 @@ def __init__(self, menu_id, title, menu_items, parent, *args, **kwargs): self.state = parent.specter_state # identifier for this menu (used e.g. as a return target) self.menu_id = menu_id + # store i18n manager for help text translation + self.i18n = parent.i18n # Fill parent self.set_width(lv.pct(100)) @@ -37,7 +43,7 @@ def __init__(self, menu_id, title, menu_items, parent, *args, **kwargs): # If ui_state has history, show back button to the left of the title if parent.ui_state and parent.ui_state.history and len(parent.ui_state.history) > 0: self.back_btn = lv.button(self) - self.back_btn.set_size(40, 28) + self.back_btn.set_size(BACK_BTN_HEIGHT, BACK_BTN_WIDTH) self.back_ico = lv.image(self.back_btn) BTC_ICONS.CARET_LEFT.add_to_parent(self.back_ico) self.back_ico.center() @@ -50,7 +56,7 @@ def __init__(self, menu_id, title, menu_items, parent, *args, **kwargs): self.title.set_text(title) self.title.set_style_text_align(lv.TEXT_ALIGN.CENTER, 0) # reduce vertical space used by the title; center remains but offset horizontally - self.title.align(lv.ALIGN.TOP_MID, 0, 6) + self.title.align(lv.ALIGN.TOP_MID, 0, 18) # Container for buttons self.container = lv.obj(self) @@ -59,13 +65,20 @@ def __init__(self, menu_id, title, menu_items, parent, *args, **kwargs): self.container.set_layout(lv.LAYOUT.FLEX) self.container.set_flex_flow(lv.FLEX_FLOW.COLUMN) self.container.set_flex_align(lv.FLEX_ALIGN.START, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER) - self.container.set_style_pad_all(PAD_SIZE, 0) + self.container.set_style_pad_all(0, 0) self.container.set_style_border_width(0, 0) # smaller gap between title and container - self.container.align_to(self.title, lv.ALIGN.OUT_BOTTOM_MID, 0, PAD_SIZE) + self.container.align_to(self.title, lv.ALIGN.OUT_BOTTOM_MID, 0, TITLE_PADDING) # Build items - for icon, text, target_behavior, color in menu_items: + for item in menu_items: + # Extract tuple elements - now expecting 6 elements: (icon, text, target_behavior, color, size, help_key) + icon, text, target_behavior, color, size, help_key = item + + # Normalize size: default to 1, ensure minimum of 1 + if size is None or size < 1: + size = 1 + if target_behavior is None: spacer = lv.label(self.container) spacer.set_recolor(True) @@ -75,7 +88,9 @@ def __init__(self, menu_id, title, menu_items, parent, *args, **kwargs): else: btn = lv.button(self.container) btn.set_width(lv.pct(BTN_WIDTH)) - btn.set_height(BTN_HEIGHT) + # Apply size scaling to button height + scaled_height = int(BTN_HEIGHT * size) + btn.set_height(scaled_height) if color: btn.set_style_bg_color(color, lv.PART.MAIN) @@ -98,6 +113,23 @@ def __init__(self, menu_id, title, menu_items, parent, *args, **kwargs): lbl.set_text(text) lbl.center() + # Add help icon on right side if help_key is provided + if help_key: + help_btn = lv.button(btn) + help_btn.set_size(28, 28) + # Make the help button transparent (no background) + help_btn.set_style_bg_opa(lv.OPA.TRANSP, 0) + help_btn.set_style_shadow_width(0, 0) + help_btn.set_style_border_width(0, 0) + help_btn.align(lv.ALIGN.RIGHT_MID, -4, 0) + + help_icon_img = lv.image(help_btn) + BTC_ICONS.QUESTION_CIRCLE.add_to_parent(help_icon_img) + help_icon_img.center() + + # Create help popup callback + help_btn.add_event_cb(self.make_help_callback(text, help_key), lv.EVENT.CLICKED, None) + btn.add_event_cb(self.make_callback(target_behavior), lv.EVENT.CLICKED, None) def make_callback(self, target_behavior): @@ -117,5 +149,61 @@ def callback(e): self.on_navigate(target_behavior) return callback + def make_help_callback(self, title_text, help_key): + """Create callback for help button - shows a modal overlay with help text.""" + def callback(e): + if e.get_code() == lv.EVENT.CLICKED: + help_text = self.i18n.t(help_key) + + modal = ModalOverlay(bg_opa=180) + sw = modal.screen_width + sh = modal.screen_height + + # --- dialog card --- + dw = sw * MODAL_WIDTH_PCT // 100 + dh = sh * MODAL_HEIGHT_PCT // 100 + dx = (sw - dw) // 2 + dy = (sh - dh) // 2 + + dialog = lv.obj(modal.overlay) + dialog.set_size(dw, dh) + dialog.set_pos(dx, dy) + dialog.set_style_radius(8, 0) + dialog.set_style_border_width(0, 0) + dialog.set_style_pad_all(12, 0) + dialog.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) + dialog.set_layout(lv.LAYOUT.FLEX) + dialog.set_flex_flow(lv.FLEX_FLOW.COLUMN) + dialog.set_flex_align(lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER) + + # title + title_lbl = lv.label(dialog) + title_lbl.set_text(title_text) + title_lbl.set_style_text_align(lv.TEXT_ALIGN.CENTER, 0) + title_lbl.set_width(lv.pct(100)) + + # body text + text_lbl = lv.label(dialog) + text_lbl.set_text(help_text) + text_lbl.set_style_text_align(lv.TEXT_ALIGN.CENTER, 0) + text_lbl.set_width(lv.pct(100)) + text_lbl.set_long_mode(lv.label.LONG_MODE.WRAP) + + # close button + close_btn = lv.button(dialog) + close_lbl = lv.label(close_btn) + close_lbl.set_text("Close") + close_lbl.center() + + def _close(ev): + if ev.get_code() == lv.EVENT.CLICKED: + modal.close() + + close_btn.add_event_cb(_close, lv.EVENT.CLICKED, None) + + # stop the underlying button from firing too + e.stop_bubbling = 1 + return callback + def on_back(self, e): self.on_navigate(None) diff --git a/scenarios/MockUI/src/MockUI/basic/modal_overlay.py b/scenarios/MockUI/src/MockUI/basic/modal_overlay.py new file mode 100644 index 000000000..eb1130a4e --- /dev/null +++ b/scenarios/MockUI/src/MockUI/basic/modal_overlay.py @@ -0,0 +1,70 @@ +"""ModalOverlay — shared base for all full-screen layer_top overlays. + +Any UI element that needs to sit above everything else (help modals, guided +tour explainers, confirmation dialogs, …) should create a ModalOverlay and +attach its content to ``self.overlay``. + +Why layer_top? + LVGL composites layer_top above every screen child regardless of z-order, + so this is the only reliable way to display a modal that: + - covers the entire display + - captures all touch input + - is not clipped by any parent container + +Usage:: + + modal = ModalOverlay(bg_opa=180) # semi-transparent dark backdrop + modal = ModalOverlay(bg_opa=lv.OPA.TRANSP) # transparent (add dim strips yourself) + + # build your content as children of modal.overlay + dialog = lv.obj(modal.overlay) + ... + + # dismiss + modal.close() +""" + +import lvgl as lv + + +class ModalOverlay: + """Full-screen container parented to ``layer_top``. + + Args: + bg_opa: Background opacity for the backdrop (0-255 or ``lv.OPA.*`` + constant). Use ``lv.OPA.TRANSP`` (0) when you want to add + your own dim strips, or a value like 180 for a simple + semi-transparent dark backdrop. + bg_color: Background colour as a hex int (default: 0x000000). + """ + + def __init__(self, bg_opa=lv.OPA.TRANSP, bg_color=0x000000): + disp = lv.display_get_default() + self._sw = disp.get_horizontal_resolution() + self._sh = disp.get_vertical_resolution() + + self.overlay = lv.obj(disp.get_layer_top()) + self.overlay.set_size(self._sw, self._sh) + self.overlay.set_pos(0, 0) + self.overlay.set_style_bg_color(lv.color_hex(bg_color), 0) + self.overlay.set_style_bg_opa(bg_opa, 0) + self.overlay.set_style_border_width(0, 0) + self.overlay.set_style_radius(0, 0) + self.overlay.set_style_pad_all(0, 0) + # set_scrollbar_mode is used instead of remove_flag(SCROLLABLE) because + # it works across all LVGL MicroPython binding variants we've encountered. + self.overlay.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) + + @property + def screen_width(self): + return self._sw + + @property + def screen_height(self): + return self._sh + + def close(self): + """Delete the overlay and all its children.""" + if self.overlay is not None: + self.overlay.delete() + self.overlay = None diff --git a/scenarios/MockUI/src/MockUI/basic/navigation_controller.py b/scenarios/MockUI/src/MockUI/basic/navigation_controller.py index 590ea12c0..af128b54c 100644 --- a/scenarios/MockUI/src/MockUI/basic/navigation_controller.py +++ b/scenarios/MockUI/src/MockUI/basic/navigation_controller.py @@ -1,7 +1,8 @@ import lvgl as lv from ..helpers import UIState, SpecterState -from .status_bar import StatusBar +from .device_bar import DeviceBar +from .wallet_bar import WalletBar from .action_screen import ActionScreen from .main_menu import MainMenu from .locked_menu import LockedMenu @@ -22,8 +23,10 @@ StorageMenu, SecurityMenu, LanguageMenu, + SettingsMenu, ) from ..i18n import I18nManager +from ..tour import GuidedTour class NavigationController(lv.obj): @@ -48,24 +51,37 @@ def __init__(self, specter_state=None, ui_state=None, *args, **kwargs): self.current_screen = None - # Create a status bar (~10%) and a content container for the screens (~90%) - self.status_bar = StatusBar(self, height_pct=5) + # Create device bar at top (5%), wallet bar at bottom (5%), and content in middle (90%) + self.device_bar = DeviceBar(self, height_pct=5) + self.device_bar.align(lv.ALIGN.TOP_MID, 0, 0) - # content area below status bar where menus will be parented + # Wallet bar at bottom + self.wallet_bar = WalletBar(self, height_pct=5) + self.wallet_bar.align(lv.ALIGN.BOTTOM_MID, 0, 0) + + # Content area in middle (scrollable) self.content = lv.obj(self) self.content.set_width(lv.pct(100)) - self.content.set_height(lv.pct(95)) + self.content.set_height(lv.pct(90)) self.content.set_layout(lv.LAYOUT.FLEX) self.content.set_flex_flow(lv.FLEX_FLOW.COLUMN) - self.content.set_style_pad_all(0, 0) # Remove padding to allow full-width content - self.content.align_to(self.status_bar, lv.ALIGN.OUT_BOTTOM_MID, 0, 0) + self.content.set_style_pad_all(0, 0) + self.content.set_style_radius(0, 0) + self.content.set_style_border_width(0, 0) + self.content.align_to(self.device_bar, lv.ALIGN.OUT_BOTTOM_MID, 0, 0) + # Enable scrolling for content area + self.content.set_scroll_dir(lv.DIR.VER) # initially show the main menu self.show_menu(None) + + # Start guided tour on first startup (after UI is fully constructed) + if self.ui_state.run_tour_on_startup: + GuidedTour(self).start() - # periodic refresh of the status bar every 30 seconds + # periodic refresh of both bars every 30 seconds def _tick(timer): - self.status_bar.refresh(self.specter_state) + self.refresh_ui() lv.timer_create(_tick, 30_000, None) @@ -79,6 +95,11 @@ def change_language(self, lang_code): # Switch language in i18n manager self.i18n.set_language(lang_code) + def refresh_ui(self): + """Centralized refresh method for all UI components.""" + self.device_bar.refresh(self.specter_state) + self.wallet_bar.refresh(self.specter_state) + def show_menu(self, target_menu_id=None): # Delete current screen (free memory) @@ -99,7 +120,7 @@ def show_menu(self, target_menu_id=None): self.ui_state.clear_history() self.ui_state.current_menu_id = "locked" self.current_screen = LockedMenu(self) - self.status_bar.refresh(self.specter_state) + self.refresh_ui() return # Create new screen (micropython doesn't support match/case) @@ -134,11 +155,13 @@ def show_menu(self, target_menu_id=None): self.current_screen = StorageMenu(self) elif current == "select_language": self.current_screen = LanguageMenu(self) + elif current == "manage_settings": + self.current_screen = SettingsMenu(self) else: # For all other actions, show a generic action screen title = (target_menu_id or "").replace("_", " ") title = title[0].upper() + title[1:] if title else "" self.current_screen = ActionScreen(title, self) - # refresh the status bar - self.status_bar.refresh(self.specter_state) + # refresh the UI + self.refresh_ui() diff --git a/scenarios/MockUI/src/MockUI/basic/status_bar.py b/scenarios/MockUI/src/MockUI/basic/status_bar.py deleted file mode 100644 index e635976e0..000000000 --- a/scenarios/MockUI/src/MockUI/basic/status_bar.py +++ /dev/null @@ -1,289 +0,0 @@ -import lvgl as lv -from ..helpers import Battery -from .ui_consts import BTC_ICON_WIDTH, GREEN_HEX, ORANGE_HEX, PAD_SIZE, RED_HEX, STATUS_BTN_HEIGHT, STATUS_BTN_WIDTH, TWO_LETTER_SYMBOL_WIDTH, THREE_LETTER_SYMBOL_WIDTH, GREEN, ORANGE, RED -from .symbol_lib import BTC_ICONS - -class StatusBar(lv.obj): - """Simple status bar with a power button. Designed to be ~10% of the screen height.""" - - def __init__(self, parent, height_pct=10, *args, **kwargs): - super().__init__(parent, *args, **kwargs) - - self.parent = parent # for callback access - - self.set_width(lv.pct(100)) - self.set_height(lv.pct(height_pct)) - - self.set_layout(lv.LAYOUT.FLEX) - self.set_flex_flow(lv.FLEX_FLOW.ROW) - self.set_flex_align( - lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER - ) - self.set_style_pad_all(0, 0) - - # Power button - self.power_btn = lv.button(self) - self.power_btn.set_size(STATUS_BTN_WIDTH, STATUS_BTN_HEIGHT) - self.power_lbl = lv.label(self.power_btn) - self.power_lbl.set_text(lv.SYMBOL.POWER) - self.power_lbl.center() - self.power_btn.add_event_cb(self.power_cb, lv.EVENT.CLICKED, None) - - # Lock button (small) - self.lock_btn = lv.button(self) - self.lock_btn.set_size(STATUS_BTN_WIDTH, STATUS_BTN_HEIGHT) - self.lock_ico = lv.image(self.lock_btn) - BTC_ICONS.UNLOCK.add_to_parent(self.lock_ico) - self.lock_ico.center() - self.lock_btn.add_event_cb(self.lock_cb, lv.EVENT.CLICKED, None) - - # Battery icon - self.batt_icon = Battery(self) - self.batt_icon.VALUE = parent.specter_state.battery_pct - self.batt_icon.update() - - # Center area: wallet name + type + net + peripheral indicators - self.wallet_name_lbl = lv.label(self) - self.wallet_name_lbl.set_text("") - # conservative fixed width for the wallet name - self.wallet_name_lbl.set_width(60) - - self.wallet_type_img = lv.image(self) - # small fixed width for the type indicator (e.g. 'MuSig'/'SiSig') - self.wallet_type_img.set_width(BTC_ICON_WIDTH) - - # Passphrase indicator (shows 'PP' when the active wallet has a passphrase configured) - self.pp_img = lv.image(self) - self.pp_img.set_width(BTC_ICON_WIDTH) - - self.net_lbl = lv.label(self) - self.net_lbl.set_text("") - self.net_lbl.set_width(35) - - # peripheral indicators – give them stable small widths so changing text won't shift layout - self.qr_img = lv.image(self) - self.qr_img.set_width(BTC_ICON_WIDTH) - - self.usb_img = lv.image(self) - self.usb_img.set_width(BTC_ICON_WIDTH) - - self.sd_img = lv.image(self) - self.sd_img.set_width(BTC_ICON_WIDTH) - - self.smartcard_img = lv.image(self) - self.smartcard_img.set_width(BTC_ICON_WIDTH) - - # Language indicator (clickable selector) - self.lang_lbl = lv.label(self) - self.lang_lbl.set_text("") - self.lang_lbl.set_width(THREE_LETTER_SYMBOL_WIDTH) - self.lang_lbl.add_flag(lv.obj.FLAG.CLICKABLE) - self.lang_lbl.add_event_cb(self.lang_clicked, lv.EVENT.CLICKED, None) - - # Language dropdown (created on demand) - self.lang_dropdown = None - - # Apply a smaller font to all labels in the status bar - self.font = lv.font_montserrat_12 - labels = [ - self.wallet_name_lbl, - self.net_lbl, - self.lang_lbl, - self.qr_img, - self.power_lbl, - ] - for ico in labels: - ico.set_style_text_font(self.font, 0) - - # Make some icons clickable to navigate quickly - # Clicking wallet-related icons opens the wallet management menu - wallet_icons = [ - self.wallet_name_lbl, - self.wallet_type_img, - self.pp_img, - self.net_lbl, - ] - - for ico in wallet_icons: - #make clickable - ico.add_flag(lv.obj.FLAG.CLICKABLE) - if ico == self.wallet_name_lbl: - ico.add_event_cb(self.wallet_name_ico_clicked, lv.EVENT.CLICKED, None) - else: - ico.add_event_cb(self.wallet_config_ico_clicked, lv.EVENT.CLICKED, None) - - # Clicking peripheral indicators opens the interfaces menu - peripheral_icons = [ - self.qr_img, - self.usb_img, - self.sd_img, - self.smartcard_img, - ] - for ico in peripheral_icons: - ico.add_flag(lv.obj.FLAG.CLICKABLE) - ico.add_event_cb(self.peripheral_ico_clicked, lv.EVENT.CLICKED, None) - - def power_cb(self, e): - if e.get_code() == lv.EVENT.CLICKED: - if self.parent.specter_state.battery_pct is None: - self.parent.specter_state.battery_pct = 50 - self.refresh(self.parent.specter_state) - else: - self.parent.specter_state.battery_pct = None - self.refresh(self.parent.specter_state) - - def lock_cb(self, e): - if e.get_code() == lv.EVENT.CLICKED: - if self.parent.specter_state.is_locked: - # unlocking should be handled by the locked screen's PIN flow - return - else: - # lock the device and force NavigationController to show the locked screen - self.parent.specter_state.lock() - # show_menu will detect is_locked and show the locked screen - self.parent.show_menu(None) - - def wallet_name_ico_clicked(self, e): - if e.get_code() == lv.EVENT.CLICKED: - if self.parent.specter_state.active_wallet is None: - if self.parent.ui_state.current_menu_id != "add_wallet": - self.parent.show_menu("add_wallet") - else: - if self.parent.ui_state.current_menu_id != "change_wallet": - self.parent.show_menu("change_wallet") - - def wallet_config_ico_clicked(self, e): - if e.get_code() == lv.EVENT.CLICKED: - if self.parent.specter_state.active_wallet is None: - if self.parent.ui_state.current_menu_id != "add_wallet": - self.parent.show_menu("add_wallet") - else: - if self.parent.ui_state.current_menu_id != "manage_wallet": - self.parent.show_menu("manage_wallet") - - def peripheral_ico_clicked(self, e): - if e.get_code() == lv.EVENT.CLICKED: - if self.parent.ui_state.current_menu_id != "interfaces": - self.parent.show_menu("interfaces") - - def lang_clicked(self, e): - """Navigate to language selection menu when language label is clicked.""" - if e.get_code() == lv.EVENT.CLICKED: - if self.parent.ui_state.current_menu_id != "select_language": - self.parent.show_menu("select_language") - - def refresh(self, state): - """Update visual elements from a SpecterState-like object.""" - # determine locked state once - locked = state.is_locked - - # battery (shared between locked/unlocked) - self.batt_icon.CHARGING = state.is_charging - if state.has_battery: - perc = state.battery_pct - self.batt_icon.VALUE = perc - self.batt_icon.update() - else: - self.batt_icon.VALUE = 100 - self.batt_icon.update() - - # language is always shown even when locked - # Get current language from i18n manager (always available via NavigationController) - lang_code = self.parent.i18n.get_language() - if not lang_code: - lang_code = "??" - self.lang_lbl.set_text(self._truncate(lang_code.upper(), 3)) - - # Now set elements that differ between locked/unlocked - if locked: - BTC_ICONS.LOCK.add_to_parent(self.lock_ico) - # hide everything else which should not be visible when locked - self.wallet_name_lbl.set_text("") - self.wallet_type_img.set_src(None) - self.pp_img.set_src(None) - self.net_lbl.set_text("") - self.qr_img.set_src(None) - self.usb_img.set_src(None) - self.sd_img.set_src(None) - self.smartcard_img.set_src(None) - else: - BTC_ICONS.UNLOCK.add_to_parent(self.lock_ico) - # wallet name and type separated into two labels (unlocked only) - if state.active_wallet is not None: - w = state.active_wallet - name = getattr(w, "name", "") or "" - self.wallet_name_lbl.set_text(self._truncate(name, 8)) - ico = BTC_ICONS.TWO_KEYS if w.isMultiSig else BTC_ICONS.KEY - ico.add_to_parent(self.wallet_type_img) - # show PP indicator if wallet reports a passphrase configured - if w.active_passphrase is not None: - BTC_ICONS.PASSWORD.add_to_parent(self.pp_img) - else: - self.pp_img.set_src(None) - # net - self.net_lbl.set_text(self._truncate(w.net or "", 4)) - else: - self.wallet_name_lbl.set_text("") - self.wallet_type_img.set_src(None) - self.pp_img.set_src(None) - self.net_lbl.set_text("") - - - # peripherals - # if feature is physically not present (hasXY = False: show nothing) - # if feature is present and only can be enabled (USB+QR): show lower case when disabled and upper case when enabled - # if feature is present and can be enabled and detected (SD + SmartCard): show lower case when enabled and upper case when also detected - if state.hasQR: - if state.enabledQR: - BTC_ICONS.QR_CODE(GREEN_HEX).add_to_parent(self.qr_img) - else: - BTC_ICONS.QR_CODE(ORANGE_HEX).add_to_parent(self.qr_img) - else: - self.qr_img.set_src(None) - - if state.hasUSB: - if state.enabledUSB: - BTC_ICONS.USB(GREEN_HEX).add_to_parent(self.usb_img) - else: - BTC_ICONS.USB(ORANGE_HEX).add_to_parent(self.usb_img) - else: - self.usb_img.set_src(None) - - if state.hasSD: - if state.enabledSD: - if state.detectedSD: - BTC_ICONS.SD_CARD(GREEN_HEX).add_to_parent(self.sd_img) - else: - BTC_ICONS.SD_CARD(ORANGE_HEX).add_to_parent(self.sd_img) - else: - BTC_ICONS.SD_CARD(RED_HEX).add_to_parent(self.sd_img) - else: - self.sd_img.set_src(None) - - if state.hasSmartCard: - if state.enabledSmartCard: - if state.detectedSmartCard: - BTC_ICONS.SMARTCARD(GREEN_HEX).add_to_parent(self.smartcard_img) - else: - BTC_ICONS.SMARTCARD(ORANGE_HEX).add_to_parent(self.smartcard_img) - else: - BTC_ICONS.SMARTCARD(RED_HEX).add_to_parent(self.smartcard_img) - else: - self.smartcard_img.set_src(None) - - # end refresh - - def _truncate(self, text, max_chars): - """Return text truncated to max_chars. Append '...' when truncated. - - This is intentionally simple and avoids any LVGL-specific API calls so - it works across MicroPython LVGL bindings without guarded checks. - """ - if not text: - return "" - s = str(text) - if len(s) <= max_chars: - return s - if max_chars <= 3: - return s[:3] - return s[: max_chars] diff --git a/scenarios/MockUI/src/MockUI/basic/ui_consts.py b/scenarios/MockUI/src/MockUI/basic/ui_consts.py index e19dcadb9..8ac500e7a 100644 --- a/scenarios/MockUI/src/MockUI/basic/ui_consts.py +++ b/scenarios/MockUI/src/MockUI/basic/ui_consts.py @@ -3,7 +3,10 @@ BTN_HEIGHT = const(50) BTN_WIDTH = const(100) # Percentage of container width -MENU_PCT = const(93) +BACK_BTN_HEIGHT = const(50) +BACK_BTN_WIDTH = const(32) +MENU_PCT = const(80) +TITLE_PADDING = const(30) STATUS_BTN_HEIGHT = const(30) STATUS_BTN_WIDTH = const(40) SWITCH_HEIGHT = const(55) @@ -11,6 +14,16 @@ PAD_SIZE = const(5) BTC_ICON_WIDTH = const(24) # width allocated to BTC icons in buttons ONE_LETTER_SYMBOL_WIDTH = const(11) # width allocated to 1-letter status symbols in the status bar + +# Modal/popup dimensions (percentage of screen) +MODAL_WIDTH_PCT = const(75) +MODAL_HEIGHT_PCT = const(75) + +# UIExplainer dimensions and style +EXPLAINER_WIDTH_PCT = const(65) # Width of explainer text box (percentage of screen) +EXPLAINER_HEIGHT_PCT = const(40) # Height of explainer text box (percentage of screen) +EXPLAINER_OVERLAY_OPA = const(200) # Opacity of dim overlay (0-255, ~80% = 200) + TWO_LETTER_SYMBOL_WIDTH = const(19) # width allocated to 2-letter status symbols in the status bar THREE_LETTER_SYMBOL_WIDTH = const(27) # width allocated to 3-letter status symbols in the status bar diff --git a/scenarios/MockUI/src/MockUI/basic/wallet_bar.py b/scenarios/MockUI/src/MockUI/basic/wallet_bar.py new file mode 100644 index 000000000..42201f12c --- /dev/null +++ b/scenarios/MockUI/src/MockUI/basic/wallet_bar.py @@ -0,0 +1,129 @@ +import lvgl as lv +from .ui_consts import BTC_ICON_WIDTH +from .symbol_lib import BTC_ICONS + + +class WalletBar(lv.obj): + """Wallet status bar showing wallet-related information. Designed to be ~5% of the screen height at the bottom.""" + + def __init__(self, parent, height_pct=5, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + + self.parent = parent # for callback access + + self.set_width(lv.pct(100)) + self.set_height(lv.pct(height_pct)) + + self.set_layout(lv.LAYOUT.FLEX) + self.set_flex_flow(lv.FLEX_FLOW.ROW) + self.set_flex_align( + lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER + ) + self.set_style_pad_all(0, 0) + self.set_style_radius(0, 0) + self.set_style_border_width(0, 0) + + # Wallet name label + self.wallet_name_lbl = lv.label(self) + self.wallet_name_lbl.set_text("") + self.wallet_name_lbl.set_width(100) + self.wallet_name_lbl.set_style_text_align(lv.TEXT_ALIGN.RIGHT, 0) + + # Wallet type indicator (single/multi-sig) + self.wallet_type_img = lv.image(self) + self.wallet_type_img.set_width(BTC_ICON_WIDTH) + + # Passphrase indicator + self.pp_img = lv.image(self) + self.pp_img.set_width(BTC_ICON_WIDTH) + + # Network label + self.net_lbl = lv.label(self) + self.net_lbl.set_text("") + self.net_lbl.set_width(70) + + # Apply smaller font + self.font = lv.font_montserrat_12 + labels = [self.wallet_name_lbl, self.net_lbl] + for lbl in labels: + lbl.set_style_text_font(self.font, 0) + + # Make wallet-related elements clickable + wallet_icons = [ + self.wallet_name_lbl, + self.wallet_type_img, + self.pp_img, + self.net_lbl, + ] + + for ico in wallet_icons: + ico.add_flag(lv.obj.FLAG.CLICKABLE) + if ico == self.wallet_name_lbl: + ico.add_event_cb(self.wallet_name_ico_clicked, lv.EVENT.CLICKED, None) + else: + ico.add_event_cb(self.wallet_config_ico_clicked, lv.EVENT.CLICKED, None) + + def wallet_name_ico_clicked(self, e): + if e.get_code() == lv.EVENT.CLICKED: + if self.parent.specter_state.active_wallet is None: + if self.parent.ui_state.current_menu_id != "add_wallet": + self.parent.show_menu("add_wallet") + else: + if self.parent.ui_state.current_menu_id != "change_wallet": + self.parent.show_menu("change_wallet") + + def wallet_config_ico_clicked(self, e): + if e.get_code() == lv.EVENT.CLICKED: + if self.parent.specter_state.active_wallet is None: + if self.parent.ui_state.current_menu_id != "add_wallet": + self.parent.show_menu("add_wallet") + else: + if self.parent.ui_state.current_menu_id != "manage_wallet": + self.parent.show_menu("manage_wallet") + + def refresh(self, state): + """Update visual elements from a SpecterState-like object.""" + locked = state.is_locked + + if locked: + # Clear all content when locked (keep bar visible with same background) + self.wallet_name_lbl.set_text("") + self.wallet_type_img.set_src(None) + self.pp_img.set_src(None) + self.net_lbl.set_text("") + else: + # Update wallet information when unlocked + if state.active_wallet is not None: + w = state.active_wallet + name = getattr(w, "name", "") or "" + self.wallet_name_lbl.set_text(self._truncate(name, 14)) + + # Wallet type icon + ico = BTC_ICONS.TWO_KEYS if w.isMultiSig else BTC_ICONS.KEY + ico.add_to_parent(self.wallet_type_img) + + # Passphrase indicator + if w.active_passphrase is not None: + BTC_ICONS.PASSWORD.add_to_parent(self.pp_img) + else: + self.pp_img.set_src(None) + + # Network + self.net_lbl.set_text(self._truncate(w.net or "", 8)) + else: + # No wallet loaded + self.wallet_name_lbl.set_text("No wallet") + self.wallet_type_img.set_src(None) + self.pp_img.set_src(None) + self.net_lbl.set_text("") + + def _truncate(self, text, max_chars): + """Return text truncated to max_chars.""" + if not text: + return "" + s = str(text) + if len(s) <= max_chars: + return s + if max_chars <= 3: + return s[:3] + return s[:max_chars] diff --git a/scenarios/MockUI/src/MockUI/device/__init__.py b/scenarios/MockUI/src/MockUI/device/__init__.py index 8628c784f..82cb8e49b 100644 --- a/scenarios/MockUI/src/MockUI/device/__init__.py +++ b/scenarios/MockUI/src/MockUI/device/__init__.py @@ -5,5 +5,6 @@ from .security_menu import SecurityMenu from .storage_menu import StorageMenu from .language_menu import LanguageMenu +from .settings_menu import SettingsMenu -__all__ = ["DeviceMenu", "FirmwareMenu", "InterfacesMenu", "BackupsMenu", "SecurityMenu", "StorageMenu", "LanguageMenu"] +__all__ = ["DeviceMenu", "FirmwareMenu", "InterfacesMenu", "BackupsMenu", "SecurityMenu", "StorageMenu", "LanguageMenu", "SettingsMenu"] diff --git a/scenarios/MockUI/src/MockUI/device/backups_menu.py b/scenarios/MockUI/src/MockUI/device/backups_menu.py index 5b4fdc1b9..ab474e5cc 100644 --- a/scenarios/MockUI/src/MockUI/device/backups_menu.py +++ b/scenarios/MockUI/src/MockUI/device/backups_menu.py @@ -15,9 +15,9 @@ def __init__(self, parent, *args, **kwargs): state = getattr(parent, "specter_state", None) menu_items = [ - (BTC_ICONS.RECEIVE, t("BACKUPS_MENU_BACKUP_TO_SD"), "backup_to_sd", None), - (BTC_ICONS.SEND, t("BACKUPS_MENU_RESTORE_FROM_SD"), "restore_from_sd", None), - (BTC_ICONS.CROSS, t("BACKUPS_MENU_REMOVE_FROM_SD"), "remove_backup_from_sd", RED_HEX), + (BTC_ICONS.RECEIVE, t("BACKUPS_MENU_BACKUP_TO_SD"), "backup_to_sd", None, None, None), + (BTC_ICONS.SEND, t("BACKUPS_MENU_RESTORE_FROM_SD"), "restore_from_sd", None, None, None), + (BTC_ICONS.CROSS, t("BACKUPS_MENU_REMOVE_FROM_SD"), "remove_backup_from_sd", RED_HEX, None, None), ] title = t("MENU_MANAGE_BACKUPS") diff --git a/scenarios/MockUI/src/MockUI/device/device_menu.py b/scenarios/MockUI/src/MockUI/device/device_menu.py index f575be512..2e412c9a1 100644 --- a/scenarios/MockUI/src/MockUI/device/device_menu.py +++ b/scenarios/MockUI/src/MockUI/device/device_menu.py @@ -9,25 +9,25 @@ def DeviceMenu(parent, *args, **kwargs): on_navigate = getattr(parent, "on_navigate", None) state = getattr(parent, "specter_state", None) - menu_items = [(None, t("MENU_MANAGE_DEVICE"), None, None)] + menu_items = [(None, t("MENU_MANAGE_DEVICE"), None, None, None, None)] if state and state.hasSD and state.enabledSD and state.detectedSD: - menu_items.append((BTC_ICONS.COPY, t("MENU_MANAGE_BACKUPS"), "manage_backups", None)) + menu_items.append((BTC_ICONS.COPY, t("MENU_MANAGE_BACKUPS"), "manage_backups", None, None, None)) if state and ((state.hasQR and state.enabledQR) or (state.hasSD and state.enabledSD and state.detectedSD) or (state.hasUSB and state.enabledUSB)): - menu_items.append((BTC_ICONS.CODE, t("MENU_MANAGE_FIRMWARE"), "manage_firmware", None)) + menu_items.append((BTC_ICONS.CODE, t("MENU_MANAGE_FIRMWARE"), "manage_firmware", None, None, None)) menu_items += [ - (BTC_ICONS.SHIELD, t("MENU_MANAGE_SECURITY"), "manage_security", None), - (BTC_ICONS.FLIP_HORIZONTAL, t("MENU_ENABLE_DISABLE_INTERFACES"), "interfaces", None), - (BTC_ICONS.PHOTO, t("DEVICE_MENU_DISPLAY"), "display_settings", None), - (lv.SYMBOL.VOLUME_MAX, t("DEVICE_MENU_SOUNDS"), "sound_settings", None), - (BTC_ICONS.MESSAGE, t("MENU_LANGUAGE"), "select_language", None) + (BTC_ICONS.SHIELD, t("MENU_MANAGE_SECURITY"), "manage_security", None, None, None), + (BTC_ICONS.FLIP_HORIZONTAL, t("MENU_ENABLE_DISABLE_INTERFACES"), "interfaces", None, None, None), + (BTC_ICONS.PHOTO, t("DEVICE_MENU_DISPLAY"), "display_settings", None, None, None), + (lv.SYMBOL.VOLUME_MAX, t("DEVICE_MENU_SOUNDS"), "sound_settings", None, None, None), + (BTC_ICONS.MESSAGE, t("MENU_LANGUAGE"), "select_language", None, None, None) ] menu_items += [ - (None, ORANGE + " " + lv.SYMBOL.WARNING+ " " + t("DEVICE_MENU_DANGERZONE") + "#", None, None), - (BTC_ICONS.ALERT_CIRCLE, t("DEVICE_MENU_WIPE"), "wipe_device", RED_HEX) + (None, ORANGE + " " + lv.SYMBOL.WARNING+ " " + t("DEVICE_MENU_DANGERZONE") + "#", None, None, None, None), + (BTC_ICONS.ALERT_CIRCLE, t("DEVICE_MENU_WIPE"), "wipe_device", RED_HEX, None, "HELP_DEVICE_MENU_WIPE") ] diff --git a/scenarios/MockUI/src/MockUI/device/firmware_menu.py b/scenarios/MockUI/src/MockUI/device/firmware_menu.py index f906ea4d3..bbeaacb73 100644 --- a/scenarios/MockUI/src/MockUI/device/firmware_menu.py +++ b/scenarios/MockUI/src/MockUI/device/firmware_menu.py @@ -20,18 +20,18 @@ def __init__(self, parent, *args, **kwargs): fw_version = state.fw_version menu_items = [ - (None, t("FIRMWARE_MENU_CURRENT_VERSION") + str(fw_version) + t("FIRMWARE_MENU_UPDATE_VIA"), None, None), + (None, t("FIRMWARE_MENU_CURRENT_VERSION") + str(fw_version) + t("FIRMWARE_MENU_UPDATE_VIA"), None, None, None, None), ] # conditional sources (guard against missing attributes) if state and getattr(state, 'hasSD', False) and getattr(state, 'enabledSD', False) and getattr(state, 'detectedSD', False): - menu_items.append((BTC_ICONS.SD_CARD, t("HARDWARE_SD_CARD"), "update_fw_sd", None)) + menu_items.append((BTC_ICONS.SD_CARD, t("HARDWARE_SD_CARD"), "update_fw_sd", None, None, None)) if state and getattr(state, 'hasUSB', False) and getattr(state, 'enabledUSB', False): - menu_items.append((BTC_ICONS.USB, t("HARDWARE_USB"), "update_fw_usb", None)) + menu_items.append((BTC_ICONS.USB, t("HARDWARE_USB"), "update_fw_usb", None, None, None)) if state and getattr(state, 'hasQR', False) and getattr(state, 'enabledQR', False): - menu_items.append((BTC_ICONS.QR_CODE, t("HARDWARE_QR_CODE"), "update_fw_qr", None)) + menu_items.append((BTC_ICONS.QR_CODE, t("HARDWARE_QR_CODE"), "update_fw_qr", None, None, None)) title = t("MENU_MANAGE_FIRMWARE") diff --git a/scenarios/MockUI/src/MockUI/device/interfaces_menu.py b/scenarios/MockUI/src/MockUI/device/interfaces_menu.py index 6cd02f60a..9c1af2628 100644 --- a/scenarios/MockUI/src/MockUI/device/interfaces_menu.py +++ b/scenarios/MockUI/src/MockUI/device/interfaces_menu.py @@ -1,5 +1,5 @@ import lvgl as lv -from ..basic import BTN_HEIGHT, BTN_WIDTH, MENU_PCT, PAD_SIZE, SWITCH_HEIGHT, SWITCH_WIDTH +from ..basic import BTN_HEIGHT, BTN_WIDTH, MENU_PCT, PAD_SIZE, SWITCH_HEIGHT, SWITCH_WIDTH, TITLE_PADDING, BACK_BTN_HEIGHT, BACK_BTN_WIDTH from ..basic.symbol_lib import BTC_ICONS class InterfacesMenu(lv.obj): @@ -24,11 +24,15 @@ def __init__(self, parent, *args, **kwargs): # layout self.set_width(lv.pct(100)) self.set_height(lv.pct(100)) + # Remove padding from base menu object to allow full-width content + self.set_style_pad_all(0, 0) + # Remove border + self.set_style_border_width(0, 0) # If ui_state has history, show back button to the left of the title if parent.ui_state and parent.ui_state.history and len(parent.ui_state.history) > 0: self.back_btn = lv.button(self) - self.back_btn.set_size(40, 28) + self.back_btn.set_size(BACK_BTN_HEIGHT, BACK_BTN_WIDTH) self.back_ico = lv.image(self.back_btn) BTC_ICONS.CARET_LEFT.add_to_parent(self.back_ico) self.back_ico.center() @@ -39,7 +43,7 @@ def __init__(self, parent, *args, **kwargs): self.title = lv.label(self) self.title.set_text(self.t("MENU_ENABLE_DISABLE_INTERFACES")) self.title.set_style_text_align(lv.TEXT_ALIGN.CENTER, 0) - self.title.align(lv.ALIGN.TOP_MID, 0, 6) + self.title.align(lv.ALIGN.TOP_MID, 0, 18) # Container for rows self.container = lv.obj(self) @@ -48,8 +52,9 @@ def __init__(self, parent, *args, **kwargs): self.container.set_layout(lv.LAYOUT.FLEX) self.container.set_flex_flow(lv.FLEX_FLOW.COLUMN) self.container.set_flex_align(lv.FLEX_ALIGN.START, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER) - self.container.set_style_pad_all(PAD_SIZE, 0) - self.container.align_to(self.title, lv.ALIGN.OUT_BOTTOM_MID, 0, PAD_SIZE) + self.container.set_style_pad_all(0, 0) + self.container.set_style_border_width(0, 0) + self.container.align_to(self.title, lv.ALIGN.OUT_BOTTOM_MID, 0, TITLE_PADDING) # Build interface rows: list of tuples (icon, label_text, state_attr) rows = [] @@ -107,8 +112,8 @@ def _handler(e, attr): setattr(self.state, attr, is_on) setattr(self.parent.specter_state, attr, is_on) - # refresh status bar - self.parent.status_bar.refresh(self.parent.specter_state) + # refresh UI + self.parent.refresh_ui() sw.add_event_cb(lambda e, a=state_attr: _handler(e, a), lv.EVENT.VALUE_CHANGED, None) diff --git a/scenarios/MockUI/src/MockUI/device/language_menu.py b/scenarios/MockUI/src/MockUI/device/language_menu.py index 7e3111343..dcb1eed08 100644 --- a/scenarios/MockUI/src/MockUI/device/language_menu.py +++ b/scenarios/MockUI/src/MockUI/device/language_menu.py @@ -1,5 +1,6 @@ import lvgl as lv from ..basic import GenericMenu +from ..basic.symbol_lib import BTC_ICONS class LanguageMenu(GenericMenu): @@ -19,15 +20,15 @@ def __init__(self, parent): label = parent.i18n.get_language_name(lang_code) # Add checkmark for currently selected language if lang_code == current_lang: - symbol = lv.SYMBOL.OK + symbol = BTC_ICONS.CHECK else: symbol = None # Pass a callback function instead of a string - menu_items.append((symbol, label, lambda e, lc=lang_code: self._on_language_selected(e, lc), None)) + menu_items.append((symbol, label, lambda e, lc=lang_code: self._on_language_selected(e, lc), None, None, None)) # Add "Load new language" option (uses default string navigation) - menu_items.append((lv.SYMBOL.DOWNLOAD, t("MENU_LOAD_NEW_LANGUAGE"), "load_language", None)) + menu_items.append((lv.SYMBOL.DOWNLOAD, t("MENU_LOAD_NEW_LANGUAGE"), "load_language", None, None, None)) # Call GenericMenu constructor super().__init__( diff --git a/scenarios/MockUI/src/MockUI/device/security_menu.py b/scenarios/MockUI/src/MockUI/device/security_menu.py index 10b06ef6e..ad44b86c5 100644 --- a/scenarios/MockUI/src/MockUI/device/security_menu.py +++ b/scenarios/MockUI/src/MockUI/device/security_menu.py @@ -9,12 +9,12 @@ def SecurityMenu(parent, *args, **kwargs): state = getattr(parent, "specter_state", None) menu_items = [ - (BTC_ICONS.PASSWORD, t("SECURITY_MENU_CHANGE_PIN"), "change_pin", None), - (BTC_ICONS.CHECK, t("SECURITY_MENU_SELF_TEST"), "self_test", None), - (None, t("SECURITY_MENU_PIN_RETRIES"), "set_allowed_pin_retries", None), - (None, t("SECURITY_MENU_PIN_ACTION"), "set_exceeded_pin_action", None), - (None, t("SECURITY_MENU_DURESS_PIN"), "set_duress_pin", None), - (None, t("SECURITY_MENU_DURESS_ACTION"), "set_duress_pin_action", None), + (BTC_ICONS.PASSWORD, t("SECURITY_MENU_CHANGE_PIN"), "change_pin", None, None, None), + (BTC_ICONS.CHECK, t("SECURITY_MENU_SELF_TEST"), "self_test", None, None, None), + (None, t("SECURITY_MENU_PIN_RETRIES"), "set_allowed_pin_retries", None, None, None), + (None, t("SECURITY_MENU_PIN_ACTION"), "set_exceeded_pin_action", None, None, None), + (None, t("SECURITY_MENU_DURESS_PIN"), "set_duress_pin", None, None, None), + (None, t("SECURITY_MENU_DURESS_ACTION"), "set_duress_pin_action", None, None, None), ] return GenericMenu("manage_security", t("MENU_MANAGE_SECURITY"), menu_items, parent, *args, **kwargs) diff --git a/scenarios/MockUI/src/MockUI/device/settings_menu.py b/scenarios/MockUI/src/MockUI/device/settings_menu.py new file mode 100644 index 000000000..43d8523e5 --- /dev/null +++ b/scenarios/MockUI/src/MockUI/device/settings_menu.py @@ -0,0 +1,23 @@ +from ..basic.menu import GenericMenu +import lvgl as lv + +from ..basic.symbol_lib import BTC_ICONS + + +def SettingsMenu(parent, *args, **kwargs): + # read state and navigation callback from the parent controller + on_navigate = getattr(parent, "on_navigate", None) + state = getattr(parent, "specter_state", None) + + # Get translation function from i18n manager (always available via NavigationController) + t = parent.i18n.t + + menu_items = [] + + # Device management + menu_items.append((BTC_ICONS.GEAR, t("MENU_MANAGE_DEVICE"), "manage_device", None, None, None)) + + # Storage management + menu_items.append((lv.SYMBOL.DRIVE, t("MENU_MANAGE_STORAGE"), "manage_storage", None, None, None)) + + return GenericMenu("manage_settings", t("MENU_MANAGE_SETTINGS"), menu_items, parent, *args, **kwargs) diff --git a/scenarios/MockUI/src/MockUI/device/storage_menu.py b/scenarios/MockUI/src/MockUI/device/storage_menu.py index f3364d8a4..b323d0964 100644 --- a/scenarios/MockUI/src/MockUI/device/storage_menu.py +++ b/scenarios/MockUI/src/MockUI/device/storage_menu.py @@ -16,13 +16,13 @@ def __init__(self, parent, *args, **kwargs): on_navigate = getattr(parent, "on_navigate", None) state = getattr(parent, "specter_state", None) - menu_items = [(None, t("MENU_MANAGE_STORAGE"), None, None)] - menu_items.append((BTC_ICONS.FILE, t("STORAGE_MENU_INTERNAL_FLASH"), "internal_flash", None)) + menu_items = [(None, t("MENU_MANAGE_STORAGE"), None, None, None, None)] + menu_items.append((BTC_ICONS.FILE, t("STORAGE_MENU_INTERNAL_FLASH"), "internal_flash", None, None, None)) if state and state.hasSmartCard and state.enabledSmartCard and state.detectedSmartCard: - menu_items.append((BTC_ICONS.SMARTCARD, t("STORAGE_MENU_SMARTCARD"), "smartcard", None)) + menu_items.append((BTC_ICONS.SMARTCARD, t("STORAGE_MENU_SMARTCARD"), "smartcard", None, None, None)) if state and state.hasSD and state.enabledSD and state.detectedSD: - menu_items.append((BTC_ICONS.SD_CARD, t("STORAGE_MENU_SD_CARD"), "sdcard", None)) + menu_items.append((BTC_ICONS.SD_CARD, t("STORAGE_MENU_SD_CARD"), "sdcard", None, None, None)) super().__init__("manage_storage", t("MENU_MANAGE_STORAGE"), menu_items, parent, *args, **kwargs) diff --git a/scenarios/MockUI/src/MockUI/helpers/ui_state.py b/scenarios/MockUI/src/MockUI/helpers/ui_state.py index 1105299a1..b6c6f24c8 100644 --- a/scenarios/MockUI/src/MockUI/helpers/ui_state.py +++ b/scenarios/MockUI/src/MockUI/helpers/ui_state.py @@ -7,6 +7,11 @@ simulator environment. """ +import json + + +CONFIG_FILE = "ui_state_config.json" + class UIState: """Small helper to track UI-level state. @@ -25,6 +30,40 @@ def __init__(self): # modal currently open (string name) or None self.modal = None + # Tour state - loaded from config file + self._run_tour_on_startup = self._load_tour_state() + + @property + def run_tour_on_startup(self): + """Whether the guided tour should run on startup.""" + return self._run_tour_on_startup + + def set_tour_completed(self): + """Mark the tour as completed and persist the state.""" + self._run_tour_on_startup = False + self._save_tour_state() + + def reset_tour(self): + """Reset tour state to run again on next startup (for testing).""" + self._run_tour_on_startup = True + self._save_tour_state() + + def _load_tour_state(self): + """Load tour completion state from config file.""" + try: + with open(CONFIG_FILE, "r") as f: + config = json.load(f) + return not config.get("tour_completed", False) + except OSError: + # File doesn't exist - first run, show tour + return True + + def _save_tour_state(self): + """Save tour completion state to config file.""" + config = {"tour_completed": not self._run_tour_on_startup} + with open(CONFIG_FILE, "w") as f: + json.dump(config, f) + # Navigation helpers def push_menu(self, menu_id): """Navigate to a new menu and push the old one on the history stack.""" diff --git a/scenarios/MockUI/src/MockUI/i18n/languages/specter_ui_de.json b/scenarios/MockUI/src/MockUI/i18n/languages/specter_ui_de.json index b77a5e541..00d5a00be 100644 --- a/scenarios/MockUI/src/MockUI/i18n/languages/specter_ui_de.json +++ b/scenarios/MockUI/src/MockUI/i18n/languages/specter_ui_de.json @@ -18,6 +18,10 @@ "text": "QR-Code scannen", "ref_en": "Scan QR" }, + "HELP_SCAN_QR": { + "text": "Verwende die Kamera um einen QR-Code zu scannen. Dies kann verwendet werden um Transaktionen zu empfangen, Wallet-Deskriptoren zu importieren oder PSBTs (Partially Signed Bitcoin Transactions) zum Signieren zu verarbeiten.", + "ref_en": "Use the camera to scan a QR code. This can be used to receive transactions, import wallet descriptors, or process PSBTs (Partially Signed Bitcoin Transactions) for signing." + }, "MAIN_MENU_LOAD_SD": { "text": "Datei von SD laden", "ref_en": "Load File from SD" @@ -32,15 +36,15 @@ }, "MAIN_MENU_CHOOSE_WALLET": { "text": "Wallet auswählen", - "ref_en": "Choose Wallet" + "ref_en": "Wallet" }, "MAIN_MENU_CHANGE_ADD_WALLET": { "text": "Wallet wechseln/hinzufügen", "ref_en": "Change/Add Wallet" }, "MAIN_MENU_MANAGE_SETTINGS": { - "text": "Einstellungen verwalten", - "ref_en": "Manage Settings" + "text": "Gerät", + "ref_en": "Device" }, "WALLET_MENU_LABEL": { @@ -84,9 +88,9 @@ "ref_en": "Export Data" }, - "ADD_WALLET_TITLE": { - "text": "Wallet hinzufügen", - "ref_en": "Add Wallet" + "ADD_WALLET_NEW_SEEDPHRASE": { + "text": "Neue Seedphrase", + "ref_en": "New Seedphrase" }, "ADD_WALLET_IMPORT_FROM": { "text": "Seedphrase importieren von", @@ -181,6 +185,10 @@ "text": "Gerät zurücksetzen", "ref_en": "Wipe Device" }, + "HELP_DEVICE_MENU_WIPE": { + "text": "WARNUNG: Dies löscht ALLE Daten auf dem Gerät permanent, einschließlich deiner Seedphrase, Wallet-Konfigurationen und Einstellungen.\nDiese Aktion kann nicht rückgängig gemacht werden.\nStelle sicher, dass du ein Backup deiner Seedphrase hast bevor du fortfährst!", + "ref_en": "WARNING: This will permanently erase ALL data on the device including your seedphrase, wallet configurations, and settings.\nThis action cannot be undone.\nMake sure you have a backup of your seedphrase before proceeding!" + }, "FIRMWARE_MENU_CURRENT_VERSION": { "text": "Aktuelle Version ", @@ -325,14 +333,18 @@ "text": "Speicher verwalten", "ref_en": "Manage Storage" }, - "MENU_GENERATE_NEW_SEEDPHRASE": { - "text": "Neue Seedphrase generieren", - "ref_en": "Generate New Seedphrase" + "MENU_GENERATE_SEEDPHRASE": { + "text": "Seedphrase generieren", + "ref_en": "Generate Seedphrase" }, "MENU_MANAGE_DEVICE": { "text": "Gerät verwalten", "ref_en": "Manage Device" }, + "MENU_MANAGE_SETTINGS": { + "text": "Einstellungen verwalten", + "ref_en": "Manage Settings" + }, "MENU_LANGUAGE": { "text": "Sprache auswählen", "ref_en": "Select Language" @@ -356,6 +368,38 @@ "HARDWARE_QR_CODE": { "text": "QR-Code", "ref_en": "QR Code" + }, + "TOUR_INTRO": { + "text": "Willkommen bei Specter! Diese kurze Tour zeigt dir die wichtigsten Funktionen deiner Hardware-Wallet.", + "ref_en": "Welcome to Specter! This quick tour will show you the main features of your hardware wallet." + }, + "TOUR_LANGUAGE": { + "text": "Tippe hier, um die Anzeigesprache zu ändern.", + "ref_en": "Tap here to change the display language." + }, + "TOUR_LOCK": { + "text": "Sperre dein Gerät, wenn du es nicht benutzt. Zum Entsperren brauchst du deine PIN.", + "ref_en": "Lock your device when not in use. You'll need your PIN to unlock." + }, + "TOUR_INTERFACES": { + "text": "Diese Symbole zeigen verfügbare Schnittstellen: QR-Scanner, USB, SD-Karte und SmartCard.", + "ref_en": "These icons show available interfaces: QR scanner, USB, SD card, and SmartCard." + }, + "TOUR_BATTERY": { + "text": "Der Batteriestatus wird hier angezeigt, wenn das Gerät mit Batterie läuft.", + "ref_en": "Battery status is shown here when running on battery power." + }, + "TOUR_POWER": { + "text": "Tippe hier, um die Batteriesimulation ein-/auszuschalten (nur Demo).", + "ref_en": "Tap here to toggle battery simulation on/off (demo only)." + }, + "TOUR_WALLET_BAR": { + "text": "Deine aktive Wallet-Info erscheint hier. Sie ist leer, bis du eine Wallet erstellst oder importierst.", + "ref_en": "Your active wallet info appears here. It's empty until you create or import a wallet." + }, + "TOUR_HELP_ICON": { + "text": "Achte auf diese Hilfe-Symbole in der App. Tippe sie an für mehr Details zu jeder Funktion.", + "ref_en": "Look for these help icons throughout the app. Tap them for more details about each feature." } } } diff --git a/scenarios/MockUI/src/MockUI/i18n/languages/specter_ui_en.json b/scenarios/MockUI/src/MockUI/i18n/languages/specter_ui_en.json index f4bc69aa3..8d0706cc7 100644 --- a/scenarios/MockUI/src/MockUI/i18n/languages/specter_ui_en.json +++ b/scenarios/MockUI/src/MockUI/i18n/languages/specter_ui_en.json @@ -9,12 +9,13 @@ "MAIN_MENU_TITLE": "What do you want to do?", "MAIN_MENU_PROCESS_INPUT": "Process input", "MAIN_MENU_SCAN_QR": "Scan QR", + "HELP_SCAN_QR": "Use the camera to scan a QR code. This can be used to receive transactions, import wallet descriptors, or process PSBTs (Partially Signed Bitcoin Transactions) for signing.", "MAIN_MENU_LOAD_SD": "Load File from SD", "MAIN_MENU_SIGN_MESSAGE": "Sign Message", "MAIN_MENU_IMPORT_SMARTCARD": "Import Seed From SmartCard", - "MAIN_MENU_CHOOSE_WALLET": "Choose Wallet", + "MAIN_MENU_CHOOSE_WALLET": "Wallet", "MAIN_MENU_CHANGE_ADD_WALLET": "Change/Add Wallet", - "MAIN_MENU_MANAGE_SETTINGS": "Manage Settings", + "MAIN_MENU_MANAGE_SETTINGS": "Device", "WALLET_MENU_LABEL": "Wallet: ", "WALLET_MENU_EXPLORE": "Explore", @@ -27,7 +28,7 @@ "WALLET_MENU_CONNECT_EXPORT": "Connect/Export", "WALLET_MENU_EXPORT_DATA": "Export Data", - "ADD_WALLET_TITLE": "Add Wallet", + "ADD_WALLET_NEW_SEEDPHRASE": "New Seedphrase", "ADD_WALLET_IMPORT_FROM": "Import Seedphrase from", "ADD_WALLET_KEYBOARD": "Keyboard", @@ -55,6 +56,7 @@ "DEVICE_MENU_ADVANCED": "Advanced Features", "DEVICE_MENU_DANGERZONE": "Dangerzone", "DEVICE_MENU_WIPE": "Wipe Device", + "HELP_DEVICE_MENU_WIPE": "WARNING: This will permanently erase ALL data on the device including your seedphrase, wallet configurations, and settings.\nThis action cannot be undone.\nMake sure you have a backup of your seedphrase before proceeding!", "FIRMWARE_MENU_CURRENT_VERSION": "Current version ", "FIRMWARE_MENU_UPDATE_VIA": ". Update via", @@ -98,14 +100,24 @@ "MENU_MANAGE_SECURITY": "Manage Security Features", "MENU_ENABLE_DISABLE_INTERFACES": "Enable/Disable Interfaces", "MENU_MANAGE_STORAGE": "Manage Storage", - "MENU_GENERATE_NEW_SEEDPHRASE": "Generate New Seedphrase", + "MENU_GENERATE_SEEDPHRASE": "Generate Seedphrase", "MENU_MANAGE_DEVICE": "Manage Device", + "MENU_MANAGE_SETTINGS": "Manage Settings", "MENU_LANGUAGE": "Select Language", "HARDWARE_SMARTCARD": "SmartCard", "HARDWARE_SD_CARD": "SD Card", "HARDWARE_INTERNAL_FLASH": "internal Flash", "HARDWARE_USB": "USB", - "HARDWARE_QR_CODE": "QR Code" + "HARDWARE_QR_CODE": "QR Code", + + "TOUR_INTRO": "Welcome to Specter! This quick tour will show you the main features of your hardware wallet.", + "TOUR_LANGUAGE": "Tap here to change the display language.", + "TOUR_LOCK": "Lock your device when not in use. You'll need your PIN to unlock.", + "TOUR_INTERFACES": "These icons show available interfaces: QR scanner, USB, SD card, and SmartCard.", + "TOUR_BATTERY": "Battery status is shown here when running on battery power.", + "TOUR_POWER": "Tap here to toggle battery simulation on/off (demo only).", + "TOUR_WALLET_BAR": "Your active wallet info appears here. It's empty until you create or import a wallet.", + "TOUR_HELP_ICON": "Look for these help icons throughout the app. Tap them for more details about each feature." } } diff --git a/scenarios/MockUI/src/MockUI/tour/__init__.py b/scenarios/MockUI/src/MockUI/tour/__init__.py new file mode 100644 index 000000000..43563dea7 --- /dev/null +++ b/scenarios/MockUI/src/MockUI/tour/__init__.py @@ -0,0 +1,6 @@ +"""Guided tour module for Specter UI.""" + +from .guided_tour import GuidedTour +from .ui_explainer import UIExplainer + +__all__ = ["GuidedTour", "UIExplainer"] diff --git a/scenarios/MockUI/src/MockUI/tour/guided_tour.py b/scenarios/MockUI/src/MockUI/tour/guided_tour.py new file mode 100644 index 000000000..4d0a29faa --- /dev/null +++ b/scenarios/MockUI/src/MockUI/tour/guided_tour.py @@ -0,0 +1,88 @@ +"""Guided tour for first-time users. + +Provides a step-by-step introduction to the Specter hardware wallet UI, +highlighting key interface elements and explaining their purpose. +""" + +from .ui_explainer import UIExplainer +from ..i18n.i18n_manager import t + + +class GuidedTour: + """Manages the startup guided tour. + + The tour highlights key UI elements and provides explanations for new users. + It runs once on first startup and can be dismissed or completed. + + Acts as the central controller - UIExplainer delegates navigation back here. + + Usage: + tour = GuidedTour(nav_controller) + tour.start() + """ + + def __init__(self, nav_controller): + """Initialize the tour with a reference to the NavigationController. + + Args: + nav_controller: The NavigationController instance (must be fully constructed) + """ + self.nav = nav_controller + self.steps = [] + self.current_index = 0 + self.current_explainer = None + + def start(self): + """Build the steps list and show the first step.""" + db = self.nav.device_bar + + # Define tour steps: (element, text, position) + # Element can be lv.obj reference, (x, y, w, h) tuple, or None + self.steps = [ + (None, t("TOUR_INTRO"), "center"), + (db.lang_lbl, t("TOUR_LANGUAGE"), "below"), + (db.lock_btn, t("TOUR_LOCK"), "below"), + (db.center_container, t("TOUR_INTERFACES"), "below"), + (db.batt_icon, t("TOUR_BATTERY"), "below"), + (db.power_btn, t("TOUR_POWER"), "below"), + (self.nav.wallet_bar, t("TOUR_WALLET_BAR"), "above"), + # Help icon: manual coordinates - approximate position for first help icon + ((435, 143, 28, 28), t("TOUR_HELP_ICON"), "left"), + ] + + self.current_index = 0 + self._show_current() + + def is_first(self): + """Return True if currently on the first step.""" + return self.current_index == 0 + + def is_last(self): + """Return True if currently on the last step.""" + return self.current_index >= len(self.steps) - 1 + + def prev(self): + """Navigate to the previous step.""" + if not self.is_first(): + self.current_explainer.hide() + self.current_index -= 1 + self._show_current() + + def next(self): + """Navigate to the next step.""" + if not self.is_last(): + self.current_explainer.hide() + self.current_index += 1 + self._show_current() + + def skip(self): + """End the tour (skip or complete).""" + self.current_explainer.hide() + self.current_explainer = None + self.nav.ui_state.set_tour_completed() + + def _show_current(self): + """Show the current step.""" + element, text, position = self.steps[self.current_index] + self.current_explainer = UIExplainer(self, element, text, position) + self.current_explainer.show() diff --git a/scenarios/MockUI/src/MockUI/tour/ui_explainer.py b/scenarios/MockUI/src/MockUI/tour/ui_explainer.py new file mode 100644 index 000000000..ac96658ee --- /dev/null +++ b/scenarios/MockUI/src/MockUI/tour/ui_explainer.py @@ -0,0 +1,312 @@ +"""UI Explainer component for guided tours / onboarding. + +Provides a spotlight/coach-mark style overlay that highlights a UI element +and displays explanatory text with navigation controls. +""" + +import lvgl as lv +from ..basic.ui_consts import ( + EXPLAINER_WIDTH_PCT, + EXPLAINER_HEIGHT_PCT, + EXPLAINER_OVERLAY_OPA, + BLACK_HEX, +) +from ..basic.symbol_lib import BTC_ICONS +from ..basic.modal_overlay import ModalOverlay + + +class UIExplainer: + """ + A spotlight-style explainer that highlights a UI element with a dimmed overlay + and displays explanatory text with navigation buttons. + + Controlled by a parent GuidedTour that manages navigation between steps. + + Args: + tour: Parent GuidedTour instance that controls navigation + highlighted_element: lv.obj to highlight, OR tuple (x, y, width, height) for manual positioning, or None + text: Explanation text to display + text_position: Position of text box - "center" (default), "above", "below", "left", "right" + """ + + def __init__(self, tour, highlighted_element, text, text_position="center"): + self.tour = tour + self.highlighted_element = highlighted_element + self.text = text + self.text_position = text_position + + # LVGL objects (created on show()) + self._modal = None + self._overlay = None # alias for self._modal.overlay, used internally + self._dim_strips = [] + self._text_box = None + + def show(self): + """Create and display the explainer overlay.""" + cutout = self._get_cutout_area() + self._modal = ModalOverlay(bg_opa=lv.OPA.TRANSP) + self._overlay = self._modal.overlay + self._create_dim_strips(cutout) + self._create_text_box(*self._calculate_text_box_position(cutout)) + + def hide(self): + """Remove and destroy all LVGL objects.""" + if self._modal is not None: + self._modal.close() + self._modal = None + self._overlay = None + self._dim_strips = [] + self._text_box = None + + def _get_cutout_area(self): + """ + Calculate the cutout area (x, y, width, height). + + Returns: + tuple: (x, y, width, height) in screen coordinates, or None if no element + """ + if self.highlighted_element is None: + # No element to highlight - full overlay + return None + elif isinstance(self.highlighted_element, tuple): + # Manual positioning + return self.highlighted_element + else: + # Get coordinates from lv.obj + obj = self.highlighted_element + # Get screen coordinates + x = obj.get_x() + y = obj.get_y() + + # Walk up parent chain to get absolute screen position + parent = obj.get_parent() + while parent is not None: + x += parent.get_x() + y += parent.get_y() + parent = parent.get_parent() + + width = obj.get_width() + height = obj.get_height() + + return (x, y, width, height) + + def _create_dim_strips(self, cutout): + """ + Create the semi-transparent overlay around the cutout (or full overlay if no cutout). + + Layout with cutout: + ┌─────────────────────────────────────┐ + │ TOP STRIP (dimmed) │ + ├─────┬──────────────────┬────────────┤ + │LEFT │ CUTOUT │ RIGHT │ + │DIM │ (transparent) │ DIM │ + ├─────┴──────────────────┴────────────┤ + │ BOTTOM STRIP (dimmed) │ + └─────────────────────────────────────┘ + + If cutout is None, creates a single full-screen dim overlay. + """ + disp = lv.display_get_default() + screen_width = disp.get_horizontal_resolution() + screen_height = disp.get_vertical_resolution() + + def add_strip(x, y, w, h): + """Create a dim strip at the given position and size.""" + strip = lv.obj(self._overlay) + strip.set_pos(x, y) + strip.set_size(w, h) + strip.set_style_bg_color(BLACK_HEX, 0) + strip.set_style_bg_opa(EXPLAINER_OVERLAY_OPA, 0) + strip.set_style_border_width(0, 0) + strip.set_style_pad_all(0, 0) + strip.set_style_radius(0, 0) + strip.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) + self._dim_strips.append(strip) + + if cutout is None: + # Full-screen dim overlay + add_strip(0, 0, screen_width, screen_height) + else: + cut_x, cut_y, cut_w, cut_h = cutout + # Top strip + if cut_y > 0: + add_strip(0, 0, screen_width, cut_y) + # Bottom strip + bottom_y = cut_y + cut_h + if bottom_y < screen_height: + add_strip(0, bottom_y, screen_width, screen_height - bottom_y) + # Left strip + if cut_x > 0: + add_strip(0, cut_y, cut_x, cut_h) + # Right strip + right_x = cut_x + cut_w + if right_x < screen_width: + add_strip(right_x, cut_y, screen_width - right_x, cut_h) + + def _create_text_box(self, box_x, box_y, box_width, box_height): + """Create the text box with explanation and navigation buttons. + + Args: + box_x: X position for the text box + box_y: Y position for the text box + box_width: Width of the text box + box_height: Height of the text box + """ + # Create text box container + self._text_box = lv.obj(self._overlay) + self._text_box.set_size(box_width, box_height) + self._text_box.set_pos(box_x, box_y) + self._text_box.set_style_pad_all(10, 0) + self._text_box.set_style_radius(8, 0) + self._text_box.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) + + # Use flex layout for vertical arrangement + self._text_box.set_layout(lv.LAYOUT.FLEX) + self._text_box.set_flex_flow(lv.FLEX_FLOW.COLUMN) + self._text_box.set_flex_align(lv.FLEX_ALIGN.SPACE_BETWEEN, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER) + + # Create text label (with flex grow to take available space) + text_container = lv.obj(self._text_box) + text_container.set_width(lv.pct(100)) + text_container.set_flex_grow(1) + text_container.set_style_pad_all(5, 0) + text_container.set_style_border_width(0, 0) + text_container.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) + + text_label = lv.label(text_container) + text_label.set_text(self.text) + text_label.set_width(lv.pct(95)) + text_label.set_style_text_align(lv.TEXT_ALIGN.CENTER, 0) + text_label.set_long_mode(lv.label.LONG_MODE.WRAP) + text_label.center() + + # Create navigation button container + nav_container = lv.obj(self._text_box) + nav_container.set_width(lv.pct(100)) + nav_container.set_height(40) + nav_container.set_layout(lv.LAYOUT.FLEX) + nav_container.set_flex_flow(lv.FLEX_FLOW.ROW) + nav_container.set_flex_align(lv.FLEX_ALIGN.SPACE_EVENLY, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER) + nav_container.set_style_pad_all(0, 0) + nav_container.set_style_border_width(0, 0) + nav_container.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) + + # Get position info from tour + is_first = self.tour.is_first() + is_last = self.tour.is_last() + + # Previous button (or invisible placeholder on first screen) + prev_btn = lv.button(nav_container) + prev_btn.set_size(50, 35) + if not is_first: + prev_icon = lv.image(prev_btn) + BTC_ICONS.CARET_LEFT.add_to_parent(prev_icon) + prev_icon.center() + prev_btn.add_event_cb(self._on_prev_clicked, lv.EVENT.CLICKED, None) + else: + prev_btn.set_style_bg_opa(lv.OPA.TRANSP, 0) + prev_btn.set_style_shadow_width(0, 0) + prev_btn.set_style_border_width(0, 0) + prev_btn.remove_flag(lv.obj.FLAG.CLICKABLE) + + # Skip/Complete button (always present) + skip_btn = lv.button(nav_container) + if is_last: + skip_btn.set_size(50, 35) + skip_icon = lv.image(skip_btn) + BTC_ICONS.CHECK.add_to_parent(skip_icon) + skip_icon.center() + else: + skip_btn.set_size(90, 35) + skip_label = lv.label(skip_btn) + skip_label.set_text("Skip Tour") + skip_label.center() + skip_btn.add_event_cb(self._on_skip_clicked, lv.EVENT.CLICKED, None) + + # Next button (or invisible placeholder on last screen) + next_btn = lv.button(nav_container) + next_btn.set_size(50, 35) + if not is_last: + next_icon = lv.image(next_btn) + BTC_ICONS.CARET_RIGHT.add_to_parent(next_icon) + next_icon.center() + next_btn.add_event_cb(self._on_next_clicked, lv.EVENT.CLICKED, None) + else: + next_btn.set_style_bg_opa(lv.OPA.TRANSP, 0) + next_btn.set_style_shadow_width(0, 0) + next_btn.set_style_border_width(0, 0) + next_btn.remove_flag(lv.obj.FLAG.CLICKABLE) + + def _calculate_text_box_position(self, cutout): + """Calculate text box dimensions and position based on text_position setting and cutout. + + Args: + cutout: Tuple (x, y, w, h) of cutout area, or None for full overlay + + Returns: + tuple: (x, y, width, height) for the text box + """ + disp = lv.display_get_default() + screen_width = disp.get_horizontal_resolution() + screen_height = disp.get_vertical_resolution() + + # Calculate box dimensions + box_width = int(screen_width * EXPLAINER_WIDTH_PCT / 100) + box_height = int(screen_height * EXPLAINER_HEIGHT_PCT / 100) + + # Center position (used as default and when no cutout) + center_x = (screen_width - box_width) // 2 + center_y = (screen_height - box_height) // 2 + + # If no cutout or center position requested, return centered + if cutout is None or self.text_position == "center" or self.text_position not in ("above", "below", "left", "right"): + return (center_x, center_y, box_width, box_height) + + cut_x, cut_y, cut_w, cut_h = cutout + margin = 10 # Margin from cutout/screen edges + + if self.text_position == "above": + # Above the cutout, centered horizontally + x = center_x + y = cut_y - box_height - margin + # Ensure it stays on screen + if y < margin: + y = margin + elif self.text_position == "below": + # Below the cutout, centered horizontally + x = center_x + y = cut_y + cut_h + margin + # Ensure it stays on screen + if y + box_height > screen_height - margin: + y = screen_height - box_height - margin + elif self.text_position == "left": + # Left of the cutout, centered vertically + x = cut_x - box_width - margin + y = center_y + # Ensure it stays on screen + if x < margin: + x = margin + elif self.text_position == "right": + # Right of the cutout, centered vertically + x = cut_x + cut_w + margin + y = center_y + # Ensure it stays on screen + if x + box_width > screen_width - margin: + x = screen_width - box_width - margin + + return (x, y, box_width, box_height) + + def _on_prev_clicked(self, e): + """Handle previous button click - delegate to tour.""" + if e.get_code() == lv.EVENT.CLICKED: + self.tour.prev() + + def _on_next_clicked(self, e): + """Handle next button click - delegate to tour.""" + if e.get_code() == lv.EVENT.CLICKED: + self.tour.next() + + def _on_skip_clicked(self, e): + """Handle skip/complete button click - delegate to tour.""" + if e.get_code() == lv.EVENT.CLICKED: + self.tour.skip() diff --git a/scenarios/MockUI/src/MockUI/wallet/add_wallet_menu.py b/scenarios/MockUI/src/MockUI/wallet/add_wallet_menu.py index 4770d9d6e..e47811457 100644 --- a/scenarios/MockUI/src/MockUI/wallet/add_wallet_menu.py +++ b/scenarios/MockUI/src/MockUI/wallet/add_wallet_menu.py @@ -18,23 +18,24 @@ def __init__(self, parent, *args, **kwargs): state = getattr(parent, "specter_state", None) menu_items = [ - (BTC_ICONS.MNEMONIC, t("MENU_GENERATE_NEW_SEEDPHRASE"), "generate_seedphrase", None), - (None, t("ADD_WALLET_IMPORT_FROM"), None, None), + (None, t("ADD_WALLET_NEW_SEEDPHRASE"), None, None, None, None), + (BTC_ICONS.MNEMONIC, t("MENU_GENERATE_SEEDPHRASE"), "generate_seedphrase", None, None, None), + (None, t("ADD_WALLET_IMPORT_FROM"), None, None, None, None), ] # Add SmartCard import if available if state and state.hasSmartCard and state.enabledSmartCard and state.detectedSmartCard: - menu_items.append((BTC_ICONS.SMARTCARD, t("HARDWARE_SMARTCARD"), "import_from_smartcard", None)) + menu_items.append((BTC_ICONS.SMARTCARD, t("HARDWARE_SMARTCARD"), "import_from_smartcard", None, None, None)) if state and state.hasQR and state.enabledQR: - menu_items.append((BTC_ICONS.QR_CODE, t("HARDWARE_QR_CODE"), "import_from_qr", None)) + menu_items.append((BTC_ICONS.QR_CODE, t("HARDWARE_QR_CODE"), "import_from_qr", None, None, None)) if state and state.hasSD and state.enabledSD and state.detectedSD: - menu_items.append((BTC_ICONS.SD_CARD, t("HARDWARE_SD_CARD"), "import_from_sd", None)) + menu_items.append((BTC_ICONS.SD_CARD, t("HARDWARE_SD_CARD"), "import_from_sd", None, None, None)) menu_items += [ - (BTC_ICONS.FILE, t("HARDWARE_INTERNAL_FLASH"), "import_from_flash", None), - (lv.SYMBOL.KEYBOARD, t("ADD_WALLET_KEYBOARD"), "import_from_keyboard", None), + (BTC_ICONS.FILE, t("HARDWARE_INTERNAL_FLASH"), "import_from_flash", None, None, None), + (lv.SYMBOL.KEYBOARD, t("ADD_WALLET_KEYBOARD"), "import_from_keyboard", None, None, None), ] - super().__init__("add_wallet", t("ADD_WALLET_TITLE"), menu_items, parent, *args, **kwargs) + super().__init__("add_wallet", t("MENU_ADD_WALLET"), menu_items, parent, *args, **kwargs) diff --git a/scenarios/MockUI/src/MockUI/wallet/connect_wallets_menu.py b/scenarios/MockUI/src/MockUI/wallet/connect_wallets_menu.py index c692237dc..3bc3beace 100644 --- a/scenarios/MockUI/src/MockUI/wallet/connect_wallets_menu.py +++ b/scenarios/MockUI/src/MockUI/wallet/connect_wallets_menu.py @@ -13,10 +13,10 @@ def __init__(self, parent, *args, **kwargs): # the actual connection logic is out of scope here; provide menu entries menu_items = [ - (None, t("CONNECT_WALLETS_SPARROW"), "connect_sparrow", None), - (None, t("CONNECT_WALLETS_NUNCHUCK"), "connect_nunchuck", None), - (None, t("CONNECT_WALLETS_BLUEWALLET"), "connect_bluewallet", None), - (None, t("CONNECT_WALLETS_OTHER"), "connect_other", None), + (None, t("CONNECT_WALLETS_SPARROW"), "connect_sparrow", None, None, None), + (None, t("CONNECT_WALLETS_NUNCHUCK"), "connect_nunchuck", None, None, None), + (None, t("CONNECT_WALLETS_BLUEWALLET"), "connect_bluewallet", None, None, None), + (None, t("CONNECT_WALLETS_OTHER"), "connect_other", None, None, None), ] super().__init__("connect_sw_wallet", t("MENU_CONNECT_SW_WALLET"), menu_items, parent, *args, **kwargs) diff --git a/scenarios/MockUI/src/MockUI/wallet/generate_seedphrase_menu.py b/scenarios/MockUI/src/MockUI/wallet/generate_seedphrase_menu.py index da77fa4ad..49e8e74ee 100644 --- a/scenarios/MockUI/src/MockUI/wallet/generate_seedphrase_menu.py +++ b/scenarios/MockUI/src/MockUI/wallet/generate_seedphrase_menu.py @@ -14,7 +14,7 @@ def __init__(self, parent, *args, **kwargs): # Get translation function from i18n manager (always available via NavigationController) t = parent.i18n.t - super().__init__("generate_seedphrase", lv.SYMBOL.LIST + " " + t("MENU_GENERATE_NEW_SEEDPHRASE"), [], parent, *args, **kwargs) + super().__init__("generate_seedphrase", t("MENU_GENERATE_SEEDPHRASE"), [], parent, *args, **kwargs) self.parent = parent self.state = getattr(parent, "specter_state", None) @@ -27,6 +27,7 @@ def __init__(self, parent, *args, **kwargs): name_row.set_flex_flow(lv.FLEX_FLOW.ROW) name_row.set_flex_align(lv.FLEX_ALIGN.START, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER) name_row.set_style_border_width(0, 0) + name_row.set_style_pad_all(0, 0) name_lbl = lv.label(name_row) name_lbl.set_text(t("GENERATE_SEED_WALLET_NAME")) @@ -60,6 +61,7 @@ def __init__(self, parent, *args, **kwargs): ms_row.set_flex_flow(lv.FLEX_FLOW.ROW) ms_row.set_flex_align(lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER) ms_row.set_style_border_width(0, 0) + ms_row.set_style_pad_all(0, 0) ms_left = lv.label(ms_row) ms_left.set_text(t("COMMON_SINGLESIG")) @@ -82,6 +84,7 @@ def __init__(self, parent, *args, **kwargs): net_row.set_flex_flow(lv.FLEX_FLOW.ROW) net_row.set_flex_align(lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER) net_row.set_style_border_width(0, 0) + net_row.set_style_pad_all(0, 0) net_left = lv.label(net_row) net_left.set_text(t("COMMON_MAINNET")) @@ -111,6 +114,7 @@ def __init__(self, parent, *args, **kwargs): create_row.set_flex_flow(lv.FLEX_FLOW.ROW) create_row.set_flex_align(lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER) create_row.set_style_border_width(0, 0) + create_row.set_style_pad_all(0, 0) self.create_btn = lv.button(create_row) self.create_btn.set_width(lv.pct(BTN_WIDTH)) diff --git a/scenarios/MockUI/src/MockUI/wallet/passphrase_menu.py b/scenarios/MockUI/src/MockUI/wallet/passphrase_menu.py index 8843dfdb6..d6a4bb595 100644 --- a/scenarios/MockUI/src/MockUI/wallet/passphrase_menu.py +++ b/scenarios/MockUI/src/MockUI/wallet/passphrase_menu.py @@ -80,8 +80,8 @@ def _on_clear(self, e): self.pa_ta.set_text("") # Clear passphrase in state self.state.active_wallet.active_passphrase = None - # Refresh status bar - self.parent.status_bar.refresh(self.state) + # Refresh UI + self.parent.refresh_ui() def _open_keyboard(self, e): """Show keyboard for editing passphrase.""" @@ -112,14 +112,16 @@ def on_keyboard_ready(e): self.state.active_wallet.active_passphrase = None else: self.state.active_wallet.active_passphrase = val - # Refresh status bar - self.parent.status_bar.refresh(self.state) + # Refresh UI + self.parent.refresh_ui() # Remove focus from text area self.pa_ta.remove_state(lv.STATE.FOCUSED) # Delete keyboard if self.keyboard: self.keyboard.delete() self.keyboard = None + # Navigate back to previous menu + self.on_navigate(None) # Add event handler for when Cancel button is pressed def on_keyboard_cancel(e): diff --git a/scenarios/MockUI/src/MockUI/wallet/seedphrase_menu.py b/scenarios/MockUI/src/MockUI/wallet/seedphrase_menu.py index 9d7ed0a4f..6928f0222 100644 --- a/scenarios/MockUI/src/MockUI/wallet/seedphrase_menu.py +++ b/scenarios/MockUI/src/MockUI/wallet/seedphrase_menu.py @@ -12,27 +12,27 @@ def SeedPhraseMenu(parent, *args, **kwargs): menu_items = [] # Show the seedphrase (requires confirmation in a real device) - menu_items.append((BTC_ICONS.VISIBLE, t("SEEDPHRASE_MENU_SHOW"), "show_seedphrase", ORANGE_HEX)) + menu_items.append((BTC_ICONS.VISIBLE, t("SEEDPHRASE_MENU_SHOW"), "show_seedphrase", ORANGE_HEX, None, None)) # Store Seedphrase group (label) - menu_items.append((None, lv.SYMBOL.DOWNLOAD + " " + t("SEEDPHRASE_MENU_STORE_TO"), None, None)) + menu_items.append((None, lv.SYMBOL.DOWNLOAD + " " + t("SEEDPHRASE_MENU_STORE_TO"), None, None, None, None)) if state and state.hasSmartCard and state.enabledSmartCard and state.detectedSmartCard: - menu_items.append((BTC_ICONS.SMARTCARD, t("HARDWARE_SMARTCARD"), "store_to_smartcard", None)) + menu_items.append((BTC_ICONS.SMARTCARD, t("HARDWARE_SMARTCARD"), "store_to_smartcard", None, None, None)) if state and state.hasSD and state.enabledSD and state.detectedSD: - menu_items.append((BTC_ICONS.SD_CARD, t("HARDWARE_SD_CARD"), "store_to_sd", None)) - menu_items.append((BTC_ICONS.FILE, t("HARDWARE_INTERNAL_FLASH"), "store_to_flash", None)) + menu_items.append((BTC_ICONS.SD_CARD, t("HARDWARE_SD_CARD"), "store_to_sd", None, None, None)) + menu_items.append((BTC_ICONS.FILE, t("HARDWARE_INTERNAL_FLASH"), "store_to_flash", None, None, None)) # Clear Seedphrase group (label) - menu_items.append((None, ORANGE + " " + lv.SYMBOL.CLOSE + " " + t("SEEDPHRASE_MENU_CLEAR_FROM") + "#", None, None)) + menu_items.append((None, ORANGE + " " + lv.SYMBOL.CLOSE + " " + t("SEEDPHRASE_MENU_CLEAR_FROM") + "#", None, None, None, None)) if state and state.hasSmartCard and state.enabledSmartCard and state.detectedSmartCard: - menu_items.append((BTC_ICONS.SMARTCARD, t("HARDWARE_SMARTCARD"), "clear_from_smartcard", RED_HEX)) + menu_items.append((BTC_ICONS.SMARTCARD, t("HARDWARE_SMARTCARD"), "clear_from_smartcard", RED_HEX, None, None)) if state and state.hasSD and state.enabledSD and state.detectedSD: - menu_items.append((BTC_ICONS.SD_CARD, t("HARDWARE_SD_CARD"), "clear_from_sd", RED_HEX)) - menu_items.append((BTC_ICONS.FILE, t("HARDWARE_INTERNAL_FLASH"), "clear_from_flash", RED_HEX)) - menu_items.append((BTC_ICONS.TRASH, t("SEEDPHRASE_MENU_CLEAR_ALL"), "clear_all_storage", RED_HEX)) + menu_items.append((BTC_ICONS.SD_CARD, t("HARDWARE_SD_CARD"), "clear_from_sd", RED_HEX, None, None)) + menu_items.append((BTC_ICONS.FILE, t("HARDWARE_INTERNAL_FLASH"), "clear_from_flash", RED_HEX, None, None)) + menu_items.append((BTC_ICONS.TRASH, t("SEEDPHRASE_MENU_CLEAR_ALL"), "clear_all_storage", RED_HEX, None, None)) # Derive new via BIP-85 - menu_items.append((None, t("SEEDPHRASE_MENU_ADVANCED"), None, None)) - menu_items.append((BTC_ICONS.SHARED_WALLET, t("SEEDPHRASE_MENU_BIP85"), "derive_bip85", None)) + menu_items.append((None, t("SEEDPHRASE_MENU_ADVANCED"), None, None, None, None)) + menu_items.append((BTC_ICONS.SHARED_WALLET, t("SEEDPHRASE_MENU_BIP85"), "derive_bip85", None, None, None)) return GenericMenu("manage_seedphrase", t("MENU_MANAGE_SEEDPHRASE"), menu_items, parent, *args, **kwargs) diff --git a/scenarios/MockUI/src/MockUI/wallet/wallet_menu.py b/scenarios/MockUI/src/MockUI/wallet/wallet_menu.py index 9a6929333..9c1b77e2b 100644 --- a/scenarios/MockUI/src/MockUI/wallet/wallet_menu.py +++ b/scenarios/MockUI/src/MockUI/wallet/wallet_menu.py @@ -1,4 +1,4 @@ -from ..basic import RED_HEX, GenericMenu, RED, ORANGE, PAD_SIZE +from ..basic import RED_HEX, GenericMenu, RED, ORANGE, TITLE_PADDING from ..basic.symbol_lib import BTC_ICONS import lvgl as lv @@ -16,26 +16,26 @@ def __init__(self, parent, *args, **kwargs): # Build menu items menu_items = [] - menu_items.append((None, t("WALLET_MENU_EXPLORE"), None, None)) - menu_items.append((BTC_ICONS.MENU, t("WALLET_MENU_VIEW_ADDRESSES"), "view_addresses", None)) + menu_items.append((None, t("WALLET_MENU_EXPLORE"), None, None, None, None)) + menu_items.append((BTC_ICONS.MENU, t("WALLET_MENU_VIEW_ADDRESSES"), "view_addresses", None, None, None)) if (state and not state.active_wallet is None and state.active_wallet.isMultiSig): - menu_items.append((BTC_ICONS.ADDRESS_BOOK, t("WALLET_MENU_VIEW_SIGNERS"), "view_signers", None)) + menu_items.append((BTC_ICONS.ADDRESS_BOOK, t("WALLET_MENU_VIEW_SIGNERS"), "view_signers", None, None, None)) - menu_items.append((None, t("WALLET_MENU_MANAGE"), None, None)) + menu_items.append((None, t("WALLET_MENU_MANAGE"), None, None, None, None)) if (state and not state.active_wallet is None and not state.active_wallet.isMultiSig): #Probably not needed as a fixed setting -> derivation path can be chosen in address explorer or when exporting public keys - #menu_items.append((None, "Manage Derivation Path", "derivation_path", None)) - menu_items.append((BTC_ICONS.MNEMONIC, t("MENU_MANAGE_SEEDPHRASE"), "manage_seedphrase", None)) - menu_items.append((BTC_ICONS.PASSWORD, t("MENU_SET_PASSPHRASE"), "set_passphrase", None)) + #menu_items.append((None, "Manage Derivation Path", "derivation_path", None, None, None)) + menu_items.append((BTC_ICONS.MNEMONIC, t("MENU_MANAGE_SEEDPHRASE"), "manage_seedphrase", None, None, None)) + menu_items.append((BTC_ICONS.PASSWORD, t("MENU_SET_PASSPHRASE"), "set_passphrase", None, None, None)) elif (state and not state.active_wallet is None and state.active_wallet.isMultiSig): - menu_items.append((BTC_ICONS.CONSOLE, t("WALLET_MENU_MANAGE_DESCRIPTOR"), "manage_wallet_descriptor", None)) - menu_items.append((BTC_ICONS.BITCOIN, t("WALLET_MENU_CHANGE_NETWORK"), "change_network", None)) + menu_items.append((BTC_ICONS.CONSOLE, t("WALLET_MENU_MANAGE_DESCRIPTOR"), "manage_wallet_descriptor", None, None, None)) + menu_items.append((BTC_ICONS.BITCOIN, t("WALLET_MENU_CHANGE_NETWORK"), "change_network", None, None, None)) menu_items += [ - (BTC_ICONS.TRASH, t("WALLET_MENU_DELETE_WALLET"), "delete_wallet", RED_HEX), - (None, t("WALLET_MENU_CONNECT_EXPORT"), None, None), - (BTC_ICONS.LINK, t("MENU_CONNECT_SW_WALLET"), "connect_sw_wallet", None), - (BTC_ICONS.EXPORT, t("WALLET_MENU_EXPORT_DATA"), "export_wallet", None) + (BTC_ICONS.TRASH, t("WALLET_MENU_DELETE_WALLET"), "delete_wallet", RED_HEX, None, None), + (None, t("WALLET_MENU_CONNECT_EXPORT"), None, None, None, None), + (BTC_ICONS.LINK, t("MENU_CONNECT_SW_WALLET"), "connect_sw_wallet", None, None, None), + (BTC_ICONS.EXPORT, t("WALLET_MENU_EXPORT_DATA"), "export_wallet", None, None, None) ] # Initialize GenericMenu with basic title (we'll customize it below) @@ -50,22 +50,16 @@ def __init__(self, parent, *args, **kwargs): self.title_anchor = lv.label(self) self.title_anchor.set_text("") self.title_anchor.set_style_text_align(lv.TEXT_ALIGN.CENTER, 0) - self.title_anchor.align(lv.ALIGN.TOP_MID, 0, 6) # Same as GenericMenu + self.title_anchor.align(lv.ALIGN.TOP_MID, 0, 18) # Same as GenericMenu - # "Wallet: " label - position at same height as title in other menus - self.wallet_label = lv.label(self) - self.wallet_label.set_text(t("WALLET_MENU_LABEL")) - self.wallet_label.set_style_text_align(lv.TEXT_ALIGN.LEFT, 0) - self.wallet_label.align(lv.ALIGN.TOP_LEFT, 50, 6) # Same Y offset as title - - # Text area for wallet name (editable) - align bottom to label bottom + # Text area for wallet name (editable) - align to be centered (like title_anchor) self.name_textarea = lv.textarea(self) - self.name_textarea.set_width(150) # Fixed width - self.name_textarea.set_one_line(True) + self.name_textarea.set_width(200) # Fixed width + self.name_textarea.set_height(40) # Fixed height + #self.name_textarea.set_one_line(True) + self.name_textarea.set_accepted_chars("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?/~ ") # No newlines self.name_textarea.set_style_text_align(lv.TEXT_ALIGN.CENTER, 0) - self.name_textarea.set_style_pad_top(2, 0) # Reduce internal padding - self.name_textarea.set_style_pad_bottom(2, 0) - self.name_textarea.align_to(self.wallet_label, lv.ALIGN.OUT_RIGHT_BOTTOM, 6, 0) # Align bottom edge + self.name_textarea.align(lv.ALIGN.TOP_MID, 0, 5) # Set initial text from active wallet wallet_name = "" @@ -73,6 +67,13 @@ def __init__(self, parent, *args, **kwargs): wallet_name = state.active_wallet.name self.name_textarea.set_text(wallet_name) + # "Wallet: " label - position to left of text area + self.wallet_label = lv.label(self) + self.wallet_label.set_text("Wallet: ") + self.wallet_label.set_style_text_align(lv.TEXT_ALIGN.RIGHT, 0) + self.wallet_label.align_to(self.name_textarea, lv.ALIGN.OUT_LEFT_MID, -6, 0) # Align vertical center + + # Edit button with icon - match height of text area, align bottom # Get the actual height of the text area self.edit_btn = lv.button(self) @@ -96,7 +97,7 @@ def __init__(self, parent, *args, **kwargs): # Position container using the anchor, not the title_container # This ensures menu buttons are centered like in GenericMenu - self.container.align_to(self.title_anchor, lv.ALIGN.OUT_BOTTOM_MID, 0, PAD_SIZE) + self.container.align_to(self.title_anchor, lv.ALIGN.OUT_BOTTOM_MID, 0, TITLE_PADDING) def show_keyboard(self, e): """Show keyboard for editing wallet name.""" @@ -125,7 +126,7 @@ def on_keyboard_ready(e): new_name = self.name_textarea.get_text() if self.state and self.state.active_wallet: self.state.active_wallet.name = new_name - self.parent.status_bar.refresh(self.state) + self.parent.refresh_ui() # Remove focus from text area self.name_textarea.remove_state(lv.STATE.FOCUSED) # Delete keyboard diff --git a/scenarios/MockUI/tests/__init__.py b/scenarios/MockUI/tests/__init__.py deleted file mode 100644 index 8701a5e64..000000000 --- a/scenarios/MockUI/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# MockUI test package diff --git a/scenarios/MockUI/tests/test_device_state.py b/scenarios/MockUI/tests/test_device_state.py deleted file mode 100644 index f985a88c0..000000000 --- a/scenarios/MockUI/tests/test_device_state.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Tests for SpecterState.""" -from MockUI.helpers.device_state import SpecterState -from MockUI.helpers.wallet import Wallet - - -class TestSpecterStateInit: - def test_defaults(self, specter_state): - assert specter_state.seed_loaded is False - assert specter_state.active_wallet is None - assert specter_state.registered_wallets == [] - assert specter_state.is_locked is False - assert specter_state.pin is None - - -class TestSpecterStateWallets: - def test_register_wallet(self, specter_state, wallet): - specter_state.register_wallet(wallet) - assert len(specter_state.registered_wallets) == 1 - assert specter_state.registered_wallets[0] is wallet - - def test_register_multiple(self, specter_state): - w1 = Wallet("W1") - w2 = Wallet("W2") - specter_state.register_wallet(w1) - specter_state.register_wallet(w2) - assert len(specter_state.registered_wallets) == 2 - - def test_set_active_wallet(self, specter_state, wallet): - specter_state.set_active_wallet(wallet) - assert specter_state.active_wallet is wallet - - def test_clear_wallets(self, specter_state, wallet): - specter_state.register_wallet(wallet) - specter_state.clear_wallets() - assert specter_state.registered_wallets == [] - - -class TestSpecterStateLocking: - def test_lock(self, specter_state): - specter_state.lock() - assert specter_state.is_locked is True - - def test_unlock_no_pin(self, specter_state): - specter_state.lock() - result = specter_state.unlock() - assert result is True - assert specter_state.is_locked is False - - def test_unlock_correct_pin(self, specter_state): - specter_state.pin = "1234" - specter_state.lock() - result = specter_state.unlock("1234") - assert result is True - assert specter_state.is_locked is False - - def test_unlock_wrong_pin(self, specter_state): - specter_state.pin = "1234" - specter_state.lock() - result = specter_state.unlock("0000") - assert result is False - assert specter_state.is_locked is True - - -class TestSpecterStatePeripherals: - def test_peripheral_defaults(self, specter_state): - assert specter_state.hasUSB is True - assert specter_state.enabledUSB is False - assert specter_state.hasQR is False - assert specter_state.hasSD is False diff --git a/scenarios/MockUI/tests/test_ui_state.py b/scenarios/MockUI/tests/test_ui_state.py deleted file mode 100644 index 5b89ca0c8..000000000 --- a/scenarios/MockUI/tests/test_ui_state.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Tests for UIState.""" -from MockUI.helpers.ui_state import UIState - - -class TestUIStateInit: - def test_defaults(self, ui_state): - assert ui_state.current_menu_id == "main" - assert ui_state.history == [] - assert ui_state.modal is None - - -class TestUIStateNavigation: - def test_push_menu(self, ui_state): - ui_state.push_menu("settings") - assert ui_state.current_menu_id == "settings" - assert ui_state.history == ["main"] - - def test_push_multiple(self, ui_state): - ui_state.push_menu("settings") - ui_state.push_menu("security") - assert ui_state.current_menu_id == "security" - assert ui_state.history == ["main", "settings"] - - def test_pop_menu(self, ui_state): - ui_state.push_menu("settings") - ui_state.pop_menu() - assert ui_state.current_menu_id == "main" - assert ui_state.history == [] - - def test_pop_empty_returns_main(self, ui_state): - ui_state.current_menu_id = "orphan" - ui_state.pop_menu() - assert ui_state.current_menu_id == "main" - - def test_clear_history(self, ui_state): - ui_state.push_menu("a") - ui_state.push_menu("b") - ui_state.clear_history() - assert ui_state.history == [] - assert ui_state.current_menu_id == "b" - - -class TestUIStateModals: - def test_open_modal(self, ui_state): - ui_state.open_modal("confirm") - assert ui_state.modal == "confirm" - assert ui_state.is_modal_open() is True - - def test_close_modal(self, ui_state): - ui_state.open_modal("confirm") - ui_state.close_modal() - assert ui_state.modal is None - assert ui_state.is_modal_open() is False - - def test_is_modal_open_default(self, ui_state): - assert ui_state.is_modal_open() is False diff --git a/scenarios/MockUI/tests/test_wallet.py b/scenarios/MockUI/tests/test_wallet.py deleted file mode 100644 index 20ae4cae3..000000000 --- a/scenarios/MockUI/tests/test_wallet.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Tests for Wallet model.""" -from MockUI.helpers.wallet import Wallet - - -class TestWallet: - def test_init_defaults(self): - w = Wallet("MyWallet") - assert w.name == "MyWallet" - assert w.xpub is None - assert w.isMultiSig is False - assert w.net == "mainnet" - assert w.active_passphrase is None - - def test_init_full(self): - w = Wallet("Test", xpub="xpub123", isMultiSig=True, net="testnet") - assert w.name == "Test" - assert w.xpub == "xpub123" - assert w.isMultiSig is True - assert w.net == "testnet" - - def test_passphrase_assignment(self, wallet): - wallet.active_passphrase = "secret" - assert wallet.active_passphrase == "secret" diff --git a/scenarios/MockUI/tests_device/conftest.py b/scenarios/MockUI/tests_device/conftest.py index f5c945a46..7dae3d075 100644 --- a/scenarios/MockUI/tests_device/conftest.py +++ b/scenarios/MockUI/tests_device/conftest.py @@ -5,8 +5,11 @@ Requirements: - STM32F469 Discovery board connected via USB - - MockUI firmware flashed (``nix develop -c make mockui`` + flash) - disco tool dependencies installed in the active venv (mpremote, click, pyserial) + +By default these tests build the MockUI firmware with German included +(ADD_LANG=de) and flash it before running. Pass --no-build-flash to skip +this step if you have already flashed a suitable binary yourself. """ import json import os @@ -25,9 +28,83 @@ "/home/marco/DATA/01_Texte/BitCoin/Specter/f469-disco_disco_tool/scripts/disco", ) +# Firmware output path produced by ``make mockui``. +_FIRMWARE = os.path.join( + os.path.dirname(__file__), "..", "..", "..", "bin", "mockui.bin" +) +# Repo root (three levels up from tests_device/) +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) + # Run through sys.executable so the test venv (with mpremote etc.) is used. _CMD = [sys.executable, DISCO_SCRIPT] +# ========================================================================= +# Language label loading — single source of truth from the JSON files. +# ========================================================================= +_LANG_DIR = os.path.abspath(os.path.join( + os.path.dirname(__file__), "..", "..", "..", + "scenarios", "MockUI", "src", "MockUI", "i18n", "languages", +)) + +def _supported_lang_codes() -> list[str]: + """Return all language codes found in the language JSON directory.""" + codes = [] + for name in sorted(os.listdir(_LANG_DIR)): + if name.startswith("specter_ui_") and name.endswith(".json"): + codes.append(name[len("specter_ui_"):-len(".json")]) + return codes + + +def _load_metadata(key: str, *lang_codes: str) -> tuple[str, ...]: + """Return the *_metadata* value for *key* from each requested language file.""" + results = [] + for lang in lang_codes: + path = os.path.join(_LANG_DIR, f"specter_ui_{lang}.json") + try: + with open(path) as f: + data = json.load(f) + v = data.get("_metadata", {}).get(key) + if v: + results.append(v) + except (FileNotFoundError, KeyError, json.JSONDecodeError): + pass + return tuple(results) + + +def _load_label(key: str, *lang_codes: str) -> tuple[str, ...]: + """Return the translated text for *key* from each requested language file. + + Unknown keys or missing files are silently skipped so the tuple always + contains only real strings. + """ + results = [] + for lang in lang_codes: + path = os.path.join(_LANG_DIR, f"specter_ui_{lang}.json") + try: + with open(path) as f: + data = json.load(f) + v = data.get("translations", {}).get(key) + if v is not None: + text = v.get("text", v) if isinstance(v, dict) else v + if text: + results.append(text) + except (FileNotFoundError, KeyError, json.JSONDecodeError): + pass + return tuple(results) + + + +def pytest_addoption(parser): + parser.addoption( + "--no-build-flash", + action="store_true", + default=False, + help=( + "Skip the build + flash step. Use only if you have already flashed " + "a MockUI binary that includes the German language pack (ADD_LANG=de)." + ), + ) + def disco_run(*args: str, timeout: int = 15, retries: int = 2) -> str: """Run ``disco ``, assert exit-code 0, return stripped stdout. @@ -119,15 +196,13 @@ def soft_reset(wait: float = 12.0, poll_interval: float = 2.0): ) -_MAIN_MENU_MARKERS = ("What do you want to do?", "Was möchtest du tun?") - - def ensure_main_menu(max_depth: int = 5): """Navigate back until we're on the main menu. Tries go_back() repeatedly. If the UI is unreachable or go_back() fails, falls back to a soft reset (Ctrl-D, USB reconnect). """ + main_menu_markers = _load_label("MAIN_MENU_TITLE", *_supported_lang_codes()) for _ in range(max_depth): try: labels = find_labels() @@ -135,7 +210,7 @@ def ensure_main_menu(max_depth: int = 5): # UI command failed — device may still be booting after a reset. time.sleep(3) continue - if any(t in labels for t in _MAIN_MENU_MARKERS): + if any(t in labels for t in main_menu_markers): return if not go_back(): # No back button — soft reset to get to a clean main menu. @@ -144,18 +219,92 @@ def ensure_main_menu(max_depth: int = 5): # Final check after exhausting retries. labels = find_labels() - assert any(t in labels for t in _MAIN_MENU_MARKERS), ( + assert any(t in labels for t in main_menu_markers), ( f"Could not navigate to main menu. Labels: {labels}" ) +def click_button(label: str) -> None: + """Assert *label* is visible and click it.""" + labels = find_labels() + assert label in labels, f"Cannot find button {label!r}. Visible labels: {labels}" + disco_run("ui", "click", label) + time.sleep(1) + + +def navigate_to_language_menu(lang: str) -> None: + """Navigate from the main menu to the language selection menu. + + *lang* is the language code currently active on the device (e.g. "en", "de"). + Button labels are resolved from the language JSON files. + """ + ensure_main_menu() + click_button(_load_label("MENU_MANAGE_SETTINGS", lang)[0]) + click_button(_load_label("MENU_MANAGE_DEVICE", lang)[0]) + click_button(_load_label("MENU_LANGUAGE", lang)[0]) + + +def ensure_english() -> None: + """Ensure the device UI is in English, switching if needed. + + Detects the current language from visible main-menu labels and, if not + English, navigates to the language menu and selects English. + """ + ensure_main_menu() + en_title = _load_label("MAIN_MENU_TITLE", "en")[0] + if en_title in find_labels(): + return + # Identify the current language and navigate accordingly. + for lang in _supported_lang_codes(): + if lang == "en": + continue + if _load_label("MAIN_MENU_TITLE", lang)[0] in find_labels(): + navigate_to_language_menu(lang) + click_button(_load_metadata("language_name", "en")[0]) + time.sleep(2) + ensure_main_menu() + return + raise RuntimeError(f"Cannot determine current UI language. Labels: {find_labels()}") + + # ========================================================================= # Fixtures # ========================================================================= @pytest.fixture(scope="session", autouse=True) -def _require_device(): - """Fail hard if the board is not reachable — these tests need real hardware.""" +def _require_device(request): + """Build firmware with German, flash it, then verify the board is reachable. + + The build+flash step is skipped only when --no-build-flash is passed. + """ + if not request.config.getoption("--no-build-flash"): + print("\n[device-tests] Building MockUI firmware with ADD_LANG=de ...") + subprocess.run( + ["nix", "develop", "-c", "make", "mockui", "ADD_LANG=de"], + cwd=_REPO_ROOT, + check=True, + ) + print("[device-tests] Flashing firmware (blocking until done) ...") + subprocess.run( + [*_CMD, "flash", "program", os.path.abspath(_FIRMWARE)], + check=True, + ) + # Flash is complete. The board resets automatically at end of flashing. + # Poll until MicroPython is responsive (up to 30s) rather than fixed sleep. + print("[device-tests] Flash done — polling until board is responsive ...") + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + time.sleep(3) + probe = subprocess.run( + [*_CMD, "repl", "exec", "print('boot-ok')"], + capture_output=True, text=True, timeout=10, + ) + if probe.returncode == 0 and "boot-ok" in probe.stdout: + time.sleep(3) # extra settle time for UI to finish rendering + break + else: + raise RuntimeError("Board did not become responsive within 30s after flash") + result = subprocess.run( [*_CMD, "repl", "exec", "print('pytest-ping')"], capture_output=True, text=True, timeout=15, @@ -166,7 +315,9 @@ def _require_device(): f" stdout: {result.stdout.strip()}\n" f" stderr: {result.stderr.strip()}" ) - # Navigate to main menu (device may be on any screen from a previous run) + # Navigate to main menu and ensure English — device may be in any state + # from a previous (possibly failed) run. time.sleep(2) ensure_main_menu() + ensure_english() yield diff --git a/scenarios/MockUI/tests_device/pytest.ini b/scenarios/MockUI/tests_device/pytest.ini index 311944d40..475c0d58b 100644 --- a/scenarios/MockUI/tests_device/pytest.ini +++ b/scenarios/MockUI/tests_device/pytest.ini @@ -3,6 +3,7 @@ # Run with: .venv/bin/pytest scenarios/MockUI/tests_device/ -v # These tests are NOT included in the default test run. testpaths = . +pythonpath = ../src python_files = test_*.py python_classes = Test* python_functions = test_* diff --git a/scenarios/MockUI/tests_device/test_i18n_device.py b/scenarios/MockUI/tests_device/test_i18n_device.py index 3d1d5a434..cf9baeca7 100644 --- a/scenarios/MockUI/tests_device/test_i18n_device.py +++ b/scenarios/MockUI/tests_device/test_i18n_device.py @@ -20,7 +20,24 @@ import pytest -from conftest import disco_run, find_labels, go_back, ensure_main_menu, soft_reset +from conftest import ( + disco_run, find_labels, go_back, ensure_main_menu, soft_reset, + navigate_to_language_menu, click_button, _load_label, _load_metadata, +) + +# Sentinel strings returned by I18nManager — must match i18n_manager.py. +STR_MISSING = "[MISSING]" +STR_UNKNOWN_KEY = "[UNKNOWN_KEY]" + +# Language codes used throughout these tests. +LANG_EN = "en" +LANG_DE = "de" + +# Labels loaded from language files — used in assertions. +_MAIN_MENU_TITLE_EN = _load_label("MAIN_MENU_TITLE", LANG_EN)[0] +_MAIN_MENU_TITLE_DE = _load_label("MAIN_MENU_TITLE", LANG_DE)[0] +_DEUTSCH = _load_metadata("language_name", LANG_DE)[0] +_ENGLISH = _load_metadata("language_name", LANG_EN)[0] class TestI18nInfrastructure: @@ -60,8 +77,8 @@ def test_i18n_repl_interface(self): "from MockUI.i18n import I18nManager; " "mgr = I18nManager(); print(repr(mgr.t('MAIN_MENU_TITLE')))", ) - assert "[MISSING]" not in output, f"[2] Translation missing: {output}" - assert "[UNKNOWN_KEY]" not in output, f"[2] Unknown key: {output}" + assert STR_MISSING not in output, f"[2] Translation missing: {output}" + assert STR_UNKNOWN_KEY not in output, f"[2] Unknown key: {output}" assert len(output.strip()) > 2, f"[2] Empty translation: {output}" # --- 3. Translate integer key --- @@ -71,8 +88,8 @@ def test_i18n_repl_interface(self): "from MockUI.i18n.translation_keys import Keys; " "mgr = I18nManager(); print(repr(mgr.t(Keys.MAIN_MENU_TITLE)))", ) - assert "[MISSING]" not in output, f"[3] Missing with integer key: {output}" - assert "[UNKNOWN_KEY]" not in output, f"[3] Unknown integer key: {output}" + assert STR_MISSING not in output, f"[3] Missing with integer key: {output}" + assert STR_UNKNOWN_KEY not in output, f"[3] Unknown integer key: {output}" # --- 4. Available languages include English --- output = disco_run( @@ -88,7 +105,7 @@ def test_i18n_repl_interface(self): "from MockUI.i18n import I18nManager; " "mgr = I18nManager(); print(mgr.get_language_name('en'))", ) - assert "English" in output, f"[5] Expected 'English', got: {output}" + assert _ENGLISH in output, f"[5] Expected {_ENGLISH}, got: {output}" # --- 6. Unknown string key → [UNKNOWN_KEY] --- output = disco_run( @@ -96,7 +113,7 @@ def test_i18n_repl_interface(self): "from MockUI.i18n import I18nManager; " "mgr = I18nManager(); print(mgr.t('NONEXISTENT_KEY_12345'))", ) - assert "[UNKNOWN_KEY]" in output, f"[6] Expected [UNKNOWN_KEY]: {output}" + assert STR_UNKNOWN_KEY in output, f"[6] Expected [UNKNOWN_KEY]: {output}" # --- 7. Out-of-range integer key → [MISSING] --- output = disco_run( @@ -104,7 +121,7 @@ def test_i18n_repl_interface(self): "from MockUI.i18n import I18nManager; " "mgr = I18nManager(); print(mgr.t(99999))", ) - assert "[MISSING]" in output, f"[7] Expected [MISSING]: {output}" + assert STR_MISSING in output, f"[7] Expected [MISSING]: {output}" # --- 8. t(None) doesn't crash --- output = disco_run( @@ -154,7 +171,7 @@ class TestI18nFunctional: """Functional test — UI navigation, language switch, persistence. This is a single scenario that walks through the full workflow: - main menu → device menu → language menu → switch to German → + main menu → settings → device menu → language menu → switch to German → verify → soft-reset → verify persistence → switch back to English. """ @@ -162,59 +179,40 @@ def test_language_navigation_switch_persistence(self): """Full round-trip: navigate, switch to non-default language, verify persistence across soft reset, restore English. + Navigation path: Main → Manage Settings → Manage Device → Select Language + (handled by navigate_to_language_menu() in conftest.py) + Sub-checks: - 1. Main menu shows English title and 'Manage Device' - 2. Device menu shows 'Select Language' - 3. Language menu shows 'English' and 'Deutsch' - 4. Switching to German updates the UI title - 5. German language survives a soft reset (Ctrl-D reboot) - 6. Switching back to English restores the UI + 1. Language menu shows 'English' and 'Deutsch' + 2. Switching to German updates the main menu title + 3. German language survives a soft reset (Ctrl-D reboot) + 4. Switching back to English restores the UI """ - # --- 1. Main menu (English) --- - ensure_main_menu() - labels = find_labels() - assert "What do you want to do?" in labels, ( - f"[1] English main menu title missing. Labels: {labels}" - ) - assert "Manage Device" in labels, ( - f"[1] 'Manage Device' missing from main menu. Labels: {labels}" - ) - - # --- 2. Device menu --- - disco_run("ui", "click", "Manage Device") - time.sleep(1) - labels = find_labels() - assert "Select Language" in labels, ( - f"[2] 'Select Language' not in device menu. Labels: {labels}" - ) - - # --- 3. Language menu --- - disco_run("ui", "click", "Select Language") - time.sleep(1) + # --- 1. Language menu --- + navigate_to_language_menu(LANG_EN) labels = find_labels() - assert "English" in labels, ( - f"[3] 'English' not in language menu. Labels: {labels}" + assert _ENGLISH in labels, ( + f"[1] '{_ENGLISH}' not in language menu. Labels: {labels}" ) - assert "Deutsch" in labels, ( - f"[3] 'Deutsch' not in language menu. Labels: {labels}" + assert _DEUTSCH in labels, ( + f"[1] '{_DEUTSCH}' not in language menu. Labels: {labels}" ) - # --- 4. Switch to German (non-default!) --- - disco_run("ui", "click", "Deutsch") + # --- 2. Switch to German (non-default!) --- + click_button(_DEUTSCH) time.sleep(2) - # After switch, UI rebuilds on device menu. Go back to main. - go_back() + ensure_main_menu() labels = find_labels() - assert "Was möchtest du tun?" in labels, ( - f"[4] German title missing after switch. Labels: {labels}" + assert _MAIN_MENU_TITLE_DE in labels, ( + f"[2] German title missing after switch. Labels: {labels}" ) - # --- 5. Persistence: German survives soft reset --- + # --- 3. Persistence: German survives soft reset --- soft_reset(wait=15) ensure_main_menu() labels = find_labels() - assert "Was möchtest du tun?" in labels, ( - f"[5] German title not restored after soft reset. Labels: {labels}" + assert _MAIN_MENU_TITLE_DE in labels, ( + f"[3] German title not restored after soft reset. Labels: {labels}" ) # Also verify via config file on flash output = disco_run( @@ -223,32 +221,22 @@ def test_language_navigation_switch_persistence(self): "d=json.load(f); f.close(); print(d['selected_language'])", ) lang = output.strip().split("\n")[-1].strip() - assert lang == "de", ( - f"[5] Config should be 'de' after reset, got: {lang!r}" - ) - - # --- 6. Switch back to English --- - labels = find_labels() - manage_label = next( - (l for l in labels if "Gerät" in l), None, - ) - assert manage_label, f"[6] Cannot find German 'Manage Device'. Labels: {labels}" - disco_run("ui", "click", manage_label) - time.sleep(1) - - labels = find_labels() - lang_label = next( - (l for l in labels if "Sprache" in l), None, - ) - assert lang_label, f"[6] Cannot find German 'Select Language'. Labels: {labels}" - disco_run("ui", "click", lang_label) - time.sleep(1) - - disco_run("ui", "click", "English") + assert lang == LANG_DE, ( + f"[3] Config should be {LANG_DE} after reset, got: {lang!r}" + ) + + # --- 4. Switch back to English --- + # Force a GC cycle to reclaim heap accumulated from repeated find_labels() + # JSON parsing. The navigation path is now 3 levels deep (vs 1 before), + # so significantly more garbage builds up. A soft reset would also work + # but gc.collect() is much faster. + disco_run("repl", "exec", "import gc; gc.collect()") + navigate_to_language_menu(LANG_DE) + click_button(_ENGLISH) time.sleep(2) - go_back() # device menu -> main + ensure_main_menu() labels = find_labels() - assert "What do you want to do?" in labels, ( - f"[6] English title missing after switching back. Labels: {labels}" + assert _MAIN_MENU_TITLE_EN in labels, ( + f"[4] English title missing after switching back. Labels: {labels}" ) diff --git a/scenarios/conftest.py b/scenarios/conftest.py index 2228f96fe..51b4d63ff 100644 --- a/scenarios/conftest.py +++ b/scenarios/conftest.py @@ -48,6 +48,7 @@ class LvMockEvent: lvgl_mock.switch = LvMockObj lvgl_mock.image = LvMockObj lvgl_mock.EVENT = LvMockEvent + lvgl_mock.OPA = type("OPA", (), {"TRANSP": 0, "COVER": 255})() lvgl_mock.ALIGN = type("ALIGN", (), {"CENTER": 0, "TOP_LEFT": 1, "TOP_RIGHT": 2, "BOTTOM_LEFT": 3, "BOTTOM_RIGHT": 4})() lvgl_mock.SYMBOL = type("SYMBOL", (), {"BATTERY_FULL": "F", "BATTERY_3": "3", "BATTERY_2": "2", "BATTERY_1": "1", "BATTERY_EMPTY": "E", "CHARGE": "C"})() lvgl_mock.FLEX_FLOW = type("FLEX_FLOW", (), {"COLUMN": 0, "ROW": 1})()