From 661f454d1490079c3287c1f05c2a8b7e9abfbcc7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:07:42 +0000 Subject: [PATCH 1/5] feat(ui): add toolbar dropdown menu support Bring over the generic toolbar dropdown infrastructure from #1028 (ToolbarMenuItem and DesiredAppAction::Menu, rendered as a popup by the top panel) so other screens can group actions behind one button. Co-Authored-By: Claude Opus 5.5 --- src/app.rs | 12 +++- src/ui/components/top_panel.rs | 117 ++++++++++++++++++++++++++++++++- 2 files changed, 125 insertions(+), 4 deletions(-) diff --git a/src/app.rs b/src/app.rs index e72948723..4762a3791 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1270,8 +1270,18 @@ pub struct AppState { prompt_was_blocking: bool, } +/// An action displayed in a toolbar dropdown. +#[derive(Debug, Clone, PartialEq)] +pub struct ToolbarMenuItem { + pub label: &'static str, + pub action: DesiredAppAction, + pub enabled: bool, + pub tooltip: &'static str, +} + #[derive(Debug, Clone, PartialEq)] pub enum DesiredAppAction { + Menu(Vec), None, Refresh, AddScreenType(Box), @@ -1283,7 +1293,7 @@ pub enum DesiredAppAction { impl DesiredAppAction { pub fn create_action(&self, app_context: &Arc) -> AppAction { match self { - DesiredAppAction::None => AppAction::None, + DesiredAppAction::None | DesiredAppAction::Menu(_) => AppAction::None, DesiredAppAction::Refresh => AppAction::Refresh, DesiredAppAction::Custom(message) => AppAction::Custom(message.clone()), DesiredAppAction::AddScreenType(screen_type) => { diff --git a/src/ui/components/top_panel.rs b/src/ui/components/top_panel.rs index d0f91b1d5..850fd84c0 100644 --- a/src/ui/components/top_panel.rs +++ b/src/ui/components/top_panel.rs @@ -298,9 +298,56 @@ fn render_top_island( // Render other buttons normally for (text, btn_act) in other_actions.into_iter().rev() { ui.add_space(3.0); - if ComponentStyles::add_toolbar_button(ui, text, network_accent) - .clicked() - { + let label = if matches!(btn_act, DesiredAppAction::Menu(_)) { + format!("{text} ▾") + } else { + text.to_owned() + }; + let response = ComponentStyles::add_toolbar_button( + ui, + &label, + network_accent, + ); + if let DesiredAppAction::Menu(items) = btn_act { + egui::Popup::new( + ui.make_persistent_id(text), + ui.ctx().clone(), + &response, + response.layer_id, + ) + .open_memory( + response + .clicked() + .then_some(egui::SetOpenCommand::Toggle), + ) + .close_behavior( + egui::PopupCloseBehavior::CloseOnClickOutside, + ) + .frame( + egui::Frame::popup(ui.style()).fill( + DashColors::popup_fill(ui.visuals().dark_mode), + ), + ) + .show(|ui| { + for item in items { + let clicked = ui + .add_enabled_ui(item.enabled, |ui| { + ComponentStyles::add_button( + ui, + egui::Button::new(item.label), + ) + }) + .inner + .clickable_tooltip(item.tooltip) + .disabled_tooltip(item.tooltip) + .clicked(); + if clicked { + action = item.action.create_action(app_context); + ui.close(); + } + } + }); + } else if response.clicked() { action = btn_act.create_action(app_context); } } @@ -479,6 +526,70 @@ pub fn add_top_panel_with_global_nav_capturing( mod tests { use super::*; + #[test] + fn advanced_menu_opens_dispatches_and_closes_in_both_themes() { + use crate::app::ToolbarMenuItem; + use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, + }; + for dark in [false, true] { + let tmp = tempfile::tempdir().expect("temp dir"); + let ctx = crate::context::test_support::test_app_context(tmp.path()); + let mut harness = Harness::builder() + .with_size(egui::vec2(1200.0, 400.0)) + .build_ui_state( + |ui, last_action| { + let action = render_top_island( + ui, + &ctx, + |_| AppAction::None, + vec![( + "Advanced", + DesiredAppAction::Menu(vec![ + ToolbarMenuItem { + label: "Import key", + action: DesiredAppAction::Custom("import".into()), + enabled: true, + tooltip: "Import a key.", + }, + ToolbarMenuItem { + label: "Full resync", + action: DesiredAppAction::Custom("resync".into()), + enabled: false, + tooltip: "Wait for sync.", + }, + ]), + )], + ); + if action != AppAction::None { + *last_action = action; + } + }, + AppAction::None, + ); + harness.ctx.set_visuals(if dark { + egui::Visuals::dark() + } else { + egui::Visuals::light() + }); + harness.run(); + assert!(harness.query_by_label("Import key").is_none()); + harness.get_by_label("Advanced ▾").click(); + harness.run(); + assert!( + harness + .get_by_label("Full resync") + .accesskit_node() + .is_disabled() + ); + harness.get_by_label("Import key").click(); + harness.run(); + assert_eq!(harness.state(), &AppAction::Custom("import".into())); + assert!(harness.query_by_label("Import key").is_none()); + } + } + /// TC-WALLETLINK-02: the Wallets page's spec exposes an **interactive** /// (`Consumed`) wallet pill with no how-to tooltip — the pill drives the /// selection here rather than pointing elsewhere. From ba7286100e424712c78f2fd17fd1bf2049d71d52 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:16:52 +0000 Subject: [PATCH 2/5] fix(identity): let users load an identity from the Identities page Users with identities already loaded had no way to reach "Load an existing identity" from the picker: the add card promised create or load but only opened creation, and the identity pill's dropdown needs a selected identity. Add an "Add" dropdown to the Identities top bar (create, load, and the Power-user test-identities entry, matching the pill's dropdown) and make the "Add a new identity" card open the same choices. Both route through the hub's existing breadcrumb add effects, so creation keeps the selected wallet preselected. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 + docs/user-stories.md | 1 + src/ui/identity/breadcrumb_switcher.rs | 108 +++++++++++++++++ src/ui/identity/hub_screen.rs | 15 ++- src/ui/identity/identity_picker_add_card.rs | 16 ++- src/ui/identity/picker.rs | 127 +++++++++++++++++--- tests/kittest/identity_hub_switcher.rs | 46 +++++++ 7 files changed, 292 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8405f96d0..4e9ebcf2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -162,6 +162,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- The Identities page lets you load an existing identity when you already have + identities. A new "Add" menu in the top bar offers "Create a new identity" and + "Load an existing identity", and the "Add a new identity" card now offers both + choices instead of opening only identity creation. + - Token detail headers sort rows by identity name, identity ID, or numeric balance; unchecked balances stay last in either direction. diff --git a/docs/user-stories.md b/docs/user-stories.md index 8a73f9f40..a764f2945 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -572,6 +572,7 @@ As a power user, I want to load an existing identity by its ID and owner private - Enter identity ID and private key. - Identity details are fetched and displayed. +- Reachable from the Identities page at any time, including when identities are already loaded: the top-bar "Add" menu and the "Add a new identity" card both offer "Load an existing identity". ### IDN-003: Load evonode/masternode identity [Superseded by MN-001] **Persona:** Priya diff --git a/src/ui/identity/breadcrumb_switcher.rs b/src/ui/identity/breadcrumb_switcher.rs index e2eb47842..e34c8002d 100644 --- a/src/ui/identity/breadcrumb_switcher.rs +++ b/src/ui/identity/breadcrumb_switcher.rs @@ -10,7 +10,9 @@ //! //! [`global_nav_switcher`]: crate::ui::components::global_nav_switcher +use crate::app::{AppAction, DesiredAppAction, ToolbarMenuItem}; use crate::context::AppContext; +use crate::model::user_role::UserRole; use crate::ui::RootScreenType; use crate::ui::components::global_nav_switcher::{self, GlobalNavEffect}; use crate::ui::state::global_nav::{IdentityPillScope, PageNavSpec, PillConsumption}; @@ -73,6 +75,57 @@ fn map_effect(effect: GlobalNavEffect) -> BreadcrumbEffect { } } +/// `AppAction::Custom` command emitted by the "Create a new identity" item. +const ADD_IDENTITY_CREATE_COMMAND: &str = "IdentityHubAddCreate"; +/// `AppAction::Custom` command emitted by the "Load an existing identity" item. +const ADD_IDENTITY_LOAD_COMMAND: &str = "IdentityHubAddLoad"; +/// `AppAction::Custom` command emitted by the Power-user test-identities item. +const CREATE_TEST_IDENTITIES_COMMAND: &str = "IdentityHubCreateTestIdentities"; + +/// Items of the hub's "Add" menu, shared by the top-bar dropdown and the +/// picker's "Add a new identity" card so both offer the same choices as the +/// identity pill's dropdown. Each item emits an `AppAction::Custom` command the +/// hub maps back via [`add_identity_command_effect`]. +pub(crate) fn add_identity_menu_items(role: UserRole) -> Vec { + let mut items = vec![ + ToolbarMenuItem { + label: "Create a new identity", + action: DesiredAppAction::Custom(ADD_IDENTITY_CREATE_COMMAND.into()), + enabled: true, + tooltip: "Create a new identity and fund it from one of your wallets.", + }, + ToolbarMenuItem { + label: "Load an existing identity", + action: DesiredAppAction::Custom(ADD_IDENTITY_LOAD_COMMAND.into()), + enabled: true, + tooltip: "Load an identity you already own, by its ID or from your wallet.", + }, + ]; + if role.at_least(UserRole::Power) { + items.push(ToolbarMenuItem { + label: "Create multiple test identities", + action: DesiredAppAction::Custom(CREATE_TEST_IDENTITIES_COMMAND.into()), + enabled: true, + tooltip: "Create identities for testing.", + }); + } + items +} + +/// Map an action produced by an [`add_identity_menu_items`] entry to the hub +/// effect it requests; `None` for any other action. +pub(crate) fn add_identity_command_effect(action: &AppAction) -> Option { + let AppAction::Custom(command) = action else { + return None; + }; + match command.as_str() { + ADD_IDENTITY_CREATE_COMMAND => Some(BreadcrumbEffect::AddIdentityCreate), + ADD_IDENTITY_LOAD_COMMAND => Some(BreadcrumbEffect::AddIdentityLoad), + CREATE_TEST_IDENTITIES_COMMAND => Some(BreadcrumbEffect::CreateTestIdentities), + _ => None, + } +} + /// Render the hub breadcrumb switcher. Delegates to the generalized global-nav /// switcher with the hub's spec and maps the effect back. pub fn render( @@ -134,4 +187,59 @@ mod tests { BreadcrumbEffect::CreateTestIdentities ); } + + /// Everyday users get create + load; the dev bulk-create entry is gated + /// to Power users and up, matching the identity pill's dropdown. + #[test] + fn add_menu_items_follow_the_user_role() { + let labels = |role| { + add_identity_menu_items(role) + .into_iter() + .map(|item| item.label) + .collect::>() + }; + assert_eq!( + labels(UserRole::Everyday), + ["Create a new identity", "Load an existing identity"] + ); + assert_eq!( + labels(UserRole::Power), + [ + "Create a new identity", + "Load an existing identity", + "Create multiple test identities" + ] + ); + assert!( + add_identity_menu_items(UserRole::Developer) + .iter() + .all(|item| item.enabled) + ); + } + + /// Every add-menu item maps back to its hub effect; unrelated actions do not. + #[test] + fn add_menu_actions_map_to_hub_effects() { + let ctx_free_action = |item: &ToolbarMenuItem| match &item.action { + DesiredAppAction::Custom(command) => AppAction::Custom(command.clone()), + other => panic!("unexpected menu action {other:?}"), + }; + let effects: Vec<_> = add_identity_menu_items(UserRole::Developer) + .iter() + .map(|item| add_identity_command_effect(&ctx_free_action(item))) + .collect(); + assert_eq!( + effects, + [ + Some(BreadcrumbEffect::AddIdentityCreate), + Some(BreadcrumbEffect::AddIdentityLoad), + Some(BreadcrumbEffect::CreateTestIdentities), + ] + ); + assert_eq!( + add_identity_command_effect(&AppAction::Custom("OpenImportSingleKey".into())), + None + ); + assert_eq!(add_identity_command_effect(&AppAction::None), None); + } } diff --git a/src/ui/identity/hub_screen.rs b/src/ui/identity/hub_screen.rs index e0e0ba688..bc27882a5 100644 --- a/src/ui/identity/hub_screen.rs +++ b/src/ui/identity/hub_screen.rs @@ -8,7 +8,7 @@ use super::breadcrumb_switcher::{self, BreadcrumbEffect}; use super::identity_hub_tab_bar::IdentityHubTabBar; -use crate::app::AppAction; +use crate::app::{AppAction, DesiredAppAction}; use crate::backend_task::dashpay::DashPayTask; use crate::backend_task::error::TaskError; use crate::backend_task::identity::IdentityTask; @@ -426,7 +426,12 @@ impl ScreenLike for IdentityHubScreen { breadcrumb_effect = breadcrumb_switcher::render(ui, &app_context, selection); AppAction::None }, - vec![], + vec![( + "Add", + DesiredAppAction::Menu(breadcrumb_switcher::add_identity_menu_items( + self.app_context.user_role(), + )), + )], ); action |= add_left_panel(ui, &self.app_context, RootScreenType::RootScreenIdentityHub); @@ -564,6 +569,12 @@ impl ScreenLike for IdentityHubScreen { } action |= self.apply_breadcrumb_effect(breadcrumb_effect); + // The top-bar "Add" menu and the picker's add card emit + // `AppAction::Custom` commands; route them like the identity pill's + // add entries. + if let Some(effect) = breadcrumb_switcher::add_identity_command_effect(&action) { + action = self.apply_breadcrumb_effect(effect); + } self.prepare_contact_info_dialog(); if let Some((dialog, key)) = &mut self.contact_info_overwrite_dialog { diff --git a/src/ui/identity/identity_picker_add_card.rs b/src/ui/identity/identity_picker_add_card.rs index 3a03009dc..bf721db61 100644 --- a/src/ui/identity/identity_picker_add_card.rs +++ b/src/ui/identity/identity_picker_add_card.rs @@ -13,8 +13,9 @@ //! `"Create a new identity or load one you already own."`. //! //! The entire card is a click target. Clicking emits a response with -//! `add_requested == true` which the picker grid uses to route to -//! `AddNewIdentityScreen` (the existing, unmodified screen). +//! `add_requested == true` and the card's `rect`, which the picker grid uses +//! to anchor a menu offering both "Create a new identity" and "Load an +//! existing identity", so the sub-line's promise holds. use super::identity_picker_card::{CARD_HEIGHT, CARD_MIN_WIDTH}; use crate::ui::components::component_trait::ComponentResponse; @@ -40,14 +41,17 @@ pub enum AddCardBorderStyle { pub struct IdentityPickerAddCardResponse { /// True when the user clicked the card this frame. pub add_requested: bool, + /// Screen rect of the card, used to anchor the create/load menu. + pub rect: Rect, changed_value: Option<()>, } impl IdentityPickerAddCardResponse { - pub(crate) fn new(add_requested: bool) -> Self { + pub(crate) fn new(add_requested: bool, rect: Rect) -> Self { let changed_value = if add_requested { Some(()) } else { None }; Self { add_requested, + rect, changed_value, } } @@ -201,7 +205,7 @@ impl IdentityPickerAddCard { WidgetInfo::labeled(WidgetType::Button, true, "Add a new identity".to_string()) }); - IdentityPickerAddCardResponse::new(response.clicked()) + IdentityPickerAddCardResponse::new(response.clicked(), rect) } } @@ -329,7 +333,7 @@ mod tests { #[test] fn response_add_requested_populates_changed_value() { - let resp = IdentityPickerAddCardResponse::new(true); + let resp = IdentityPickerAddCardResponse::new(true, Rect::NOTHING); assert!(resp.has_changed()); assert!(resp.is_valid()); assert_eq!(resp.changed_value(), &Some(())); @@ -337,7 +341,7 @@ mod tests { #[test] fn response_not_requested_has_no_change() { - let resp = IdentityPickerAddCardResponse::new(false); + let resp = IdentityPickerAddCardResponse::new(false, Rect::NOTHING); assert!(!resp.has_changed()); assert_eq!(resp.changed_value(), &None); } diff --git a/src/ui/identity/picker.rs b/src/ui/identity/picker.rs index 287495834..5f9354e90 100644 --- a/src/ui/identity/picker.rs +++ b/src/ui/identity/picker.rs @@ -5,21 +5,21 @@ //! Layout: a responsive grid of [`IdentityPickerCard`]s followed by an //! [`IdentityPickerAddCard`]. Cards flow left-to-right, wrapping based on the //! available panel width. Clicking an identity card is reported to the caller -//! so the hub can route to Identity Home. Clicking the add card routes to the -//! **existing** `AddNewIdentityScreen` via `AppAction::AddScreen` — no new -//! navigation surface is introduced. +//! so the hub can route to Identity Home. Clicking the add card opens a menu +//! with the hub's shared "Add" items (create a new identity / load an existing +//! one); the chosen item's `AppAction::Custom` command is returned for the hub +//! to route to the **existing** screens — no new navigation surface. //! //! This module is the UI shell only. No backend tasks are dispatched here — //! identity lookup is handled upstream by `IdentityHubScreen::landing()`. +use super::breadcrumb_switcher::add_identity_menu_items; use super::identity_picker_add_card::IdentityPickerAddCard; use super::identity_picker_card::{CARD_MIN_WIDTH, IdentityPickerCard}; use crate::app::AppAction; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; -use crate::ui::Screen; -use crate::ui::identity::add_new_identity_screen::AddNewIdentityScreen; -use crate::ui::theme::DashColors; +use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use eframe::egui::{RichText, Ui}; @@ -35,8 +35,9 @@ const EMPTY_BALANCE_LABEL: &str = "No balance"; /// Rendered the picker grid. Returns the `AppAction` the caller must propagate: /// /// * `AppAction::None` on hover / no interaction. -/// * `AppAction::AddScreen(Screen::AddNewIdentityScreen(...))` when the -/// "Add a new identity" card is clicked. +/// * The chosen item's `AppAction::Custom` command when an entry of the +/// "Add a new identity" card's menu is clicked; the hub maps it with +/// [`super::breadcrumb_switcher::add_identity_command_effect`]. /// * For identity-card clicks the action is `AppAction::None` by default — /// identity selection is deferred to a follow-up task (Home-tab routing /// lands in T8); the visible banner is left to the hub screen. @@ -122,16 +123,7 @@ pub fn render( PickerCell::Add => { let card = IdentityPickerAddCard::new().with_width(card_width); let response = card.show(ui); - if response.add_requested { - // Navigate to the existing AddNewIdentityScreen — - // no duplicated screen, no new backend task. - action = AppAction::AddScreen(Screen::AddNewIdentityScreen( - AddNewIdentityScreen::new_with_wallet( - app_context, - app_context.selected_wallet_hash(), - ), - )); - } + action |= show_add_menu(ui, app_context, &response); } } } @@ -152,6 +144,43 @@ pub fn render( action } +/// Render the create/load menu anchored to the add card; a card click toggles +/// it. Returns the clicked item's action, or `AppAction::None`. +fn show_add_menu( + ui: &Ui, + app_context: &Arc, + card: &super::identity_picker_add_card::IdentityPickerAddCardResponse, +) -> AppAction { + let mut action = AppAction::None; + let dark_mode = ui.visuals().dark_mode; + egui::Popup::new( + egui::Id::new("identity_picker_add_menu"), + ui.ctx().clone(), + card.rect, + ui.layer_id(), + ) + .open_memory(card.add_requested.then_some(egui::SetOpenCommand::Toggle)) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .frame(egui::Frame::popup(ui.style()).fill(DashColors::popup_fill(dark_mode))) + .show(|ui| { + for item in add_identity_menu_items(app_context.user_role()) { + let clicked = ui + .add_enabled_ui(item.enabled, |ui| { + ComponentStyles::add_button(ui, egui::Button::new(item.label)) + }) + .inner + .clickable_tooltip(item.tooltip) + .disabled_tooltip(item.tooltip) + .clicked(); + if clicked { + action = item.action.create_action(app_context); + ui.close(); + } + } + }); + action +} + /// Compute how many cards fit per row at the given available width. Matches /// the design-spec `minmax(260px, 1fr)` rule — at least 260 px per column, /// stretching to fill the remaining space when extra room exists. @@ -206,6 +235,68 @@ fn build_card(identity: &QualifiedIdentity, display_name: Option<&str>) -> Ident mod tests { use super::*; + /// The "Add a new identity" card keeps its promise: clicking it offers both + /// create and load, and picking "Load an existing identity" returns the + /// load command the hub routes to `AddExistingIdentityScreen`. + #[test] + fn add_card_offers_create_and_load_in_both_themes() { + use super::super::breadcrumb_switcher::{BreadcrumbEffect, add_identity_command_effect}; + use egui_kittest::{Harness, kittest::Queryable}; + for dark in [false, true] { + let tmp = tempfile::tempdir().expect("temp dir"); + let ctx = crate::context::test_support::test_app_context(tmp.path()); + let mut profiles = super::super::profile_cache::ProfileCache::default(); + let mut avatars = crate::ui::state::AvatarCache::new(); + let mut pending = Vec::new(); + let mut harness = Harness::builder() + .with_size(egui::vec2(1200.0, 800.0)) + .build_ui_state( + |ui, last_action| { + let action = render( + ui, + &ctx, + &[], + &mut profiles, + &mut avatars, + &mut pending, + None, + ); + if action != AppAction::None { + *last_action = action; + } + }, + AppAction::None, + ); + harness.ctx.set_visuals(if dark { + egui::Visuals::dark() + } else { + egui::Visuals::light() + }); + harness.run(); + assert!( + harness + .query_by_label("Load an existing identity") + .is_none() + ); + harness + .get_by_role_and_label(egui::accesskit::Role::Button, "Add a new identity") + .click(); + harness.run(); + assert!(harness.query_by_label("Create a new identity").is_some()); + harness.get_by_label("Load an existing identity").click(); + harness.run(); + assert_eq!( + add_identity_command_effect(harness.state()), + Some(BreadcrumbEffect::AddIdentityLoad) + ); + assert!( + harness + .query_by_label("Load an existing identity") + .is_none() + ); + } + } + #[test] fn column_count_narrow_panel_is_one() { // 260 px viewport — exactly one column worth of space. diff --git a/tests/kittest/identity_hub_switcher.rs b/tests/kittest/identity_hub_switcher.rs index 203f1a260..99f167cdd 100644 --- a/tests/kittest/identity_hub_switcher.rs +++ b/tests/kittest/identity_hub_switcher.rs @@ -1126,3 +1126,49 @@ fn hidden_hub_receives_its_profile_save_and_releases_the_next_draft() { ); }); } + +/// With identities already loaded and none selected (the picker), the hub's +/// top-bar "Add" menu offers both add flows: "Create a new identity" pushes +/// `AddNewIdentityScreen` and "Load an existing identity" pushes +/// `AddExistingIdentityScreen`. +#[test] +fn picker_top_bar_add_menu_creates_or_loads_an_identity() { + with_isolated_data_dir(|| { + use dash_evo_tool::ui::Screen; + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenIdentityHub); + let app_context = harness.state().current_app_context().clone(); + seed_identity(&app_context, 0xC1, "Add Menu Alpha"); + seed_identity(&app_context, 0xC2, "Add Menu Beta"); + harness.run_steps(5); + assert!(harness.query_by_label(PICKER_HEADING).is_some()); + + harness.get_by_label("Add ▾").click(); + harness.run_steps(3); + harness.get_by_label("Load an existing identity").click(); + harness.run_steps(3); + assert!( + matches!( + harness.state().screen_stack.last(), + Some(Screen::AddExistingIdentityScreen(_)) + ), + "\"Load an existing identity\" must push AddExistingIdentityScreen" + ); + + harness.state_mut().screen_stack.clear(); + harness.run_steps(3); + harness.get_by_label("Add ▾").click(); + harness.run_steps(3); + harness.get_by_label("Create a new identity").click(); + harness.run_steps(3); + assert!( + matches!( + harness.state().screen_stack.last(), + Some(Screen::AddNewIdentityScreen(_)) + ), + "\"Create a new identity\" must push AddNewIdentityScreen" + ); + }); +} From 56c7ebee55d08e9243c6546d14e755064ff47d23 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:47:19 +0000 Subject: [PATCH 3/5] fix(identity): drop the unwired "Create multiple test identities" entry No bulk-creation screen exists, so the entry only opened the single identity creation screen. Remove it from the Add menu and the identity pill dropdown until IDH-005 is implemented. Also route the picker scroll test through the Add card's new create/load menu. Co-Authored-By: Claude Opus 5.5 --- src/ui/components/global_nav_switcher.rs | 9 ---- src/ui/components/top_panel.rs | 2 +- src/ui/identity/breadcrumb_switcher.rs | 59 +++++------------------- src/ui/identity/hub_screen.rs | 8 +--- src/ui/identity/picker.rs | 2 +- tests/kittest/identity_hub_switcher.rs | 5 ++ 6 files changed, 20 insertions(+), 65 deletions(-) diff --git a/src/ui/components/global_nav_switcher.rs b/src/ui/components/global_nav_switcher.rs index c91975e5a..154a8cb7a 100644 --- a/src/ui/components/global_nav_switcher.rs +++ b/src/ui/components/global_nav_switcher.rs @@ -16,7 +16,6 @@ use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; -use crate::model::user_role::UserRole; use crate::model::wallet::WalletSeedHash; use crate::model::wallet_association::{NOT_IN_WALLET_LABEL, WalletAssociation}; use crate::ui::RootScreenType; @@ -62,8 +61,6 @@ pub enum GlobalNavEffect { AddIdentityCreate, /// "Add another identity" → load an existing identity. AddIdentityLoad, - /// Dev-mode: bulk-create test identities. - CreateTestIdentities, } /// Wallet-pill mode by HD-wallet count: 0 → placeholder, 1 → subdued (info @@ -614,12 +611,6 @@ fn render_app_global_identity_pill( *effect = GlobalNavEffect::AddIdentityLoad; ui.close(); } - if app_context.user_role().at_least(UserRole::Power) - && ui.button("Create multiple test identities").clicked() - { - *effect = GlobalNavEffect::CreateTestIdentities; - ui.close(); - } }); }); } diff --git a/src/ui/components/top_panel.rs b/src/ui/components/top_panel.rs index 850fd84c0..abf7731e7 100644 --- a/src/ui/components/top_panel.rs +++ b/src/ui/components/top_panel.rs @@ -453,7 +453,7 @@ pub fn apply_global_nav_effect( GlobalNavEffect::AddWallet => { AppAction::SetMainScreen(RootScreenType::RootScreenWalletsBalances) } - GlobalNavEffect::AddIdentityCreate | GlobalNavEffect::CreateTestIdentities => { + GlobalNavEffect::AddIdentityCreate => { AppAction::AddScreen(ScreenType::AddNewIdentity.create_screen(app_context)) } GlobalNavEffect::AddIdentityLoad => { diff --git a/src/ui/identity/breadcrumb_switcher.rs b/src/ui/identity/breadcrumb_switcher.rs index e34c8002d..befd88c43 100644 --- a/src/ui/identity/breadcrumb_switcher.rs +++ b/src/ui/identity/breadcrumb_switcher.rs @@ -12,7 +12,6 @@ use crate::app::{AppAction, DesiredAppAction, ToolbarMenuItem}; use crate::context::AppContext; -use crate::model::user_role::UserRole; use crate::ui::RootScreenType; use crate::ui::components::global_nav_switcher::{self, GlobalNavEffect}; use crate::ui::state::global_nav::{IdentityPillScope, PageNavSpec, PillConsumption}; @@ -41,8 +40,6 @@ pub enum BreadcrumbEffect { AddIdentityCreate, /// "Add another identity" → load an existing identity. AddIdentityLoad, - /// Dev-mode: bulk-create test identities. - CreateTestIdentities, } /// The hub's page-nav spec: `Identities` segment-1 linking to the hub root, an @@ -71,7 +68,6 @@ fn map_effect(effect: GlobalNavEffect) -> BreadcrumbEffect { GlobalNavEffect::AddWallet => BreadcrumbEffect::AddWallet, GlobalNavEffect::AddIdentityCreate => BreadcrumbEffect::AddIdentityCreate, GlobalNavEffect::AddIdentityLoad => BreadcrumbEffect::AddIdentityLoad, - GlobalNavEffect::CreateTestIdentities => BreadcrumbEffect::CreateTestIdentities, } } @@ -79,15 +75,13 @@ fn map_effect(effect: GlobalNavEffect) -> BreadcrumbEffect { const ADD_IDENTITY_CREATE_COMMAND: &str = "IdentityHubAddCreate"; /// `AppAction::Custom` command emitted by the "Load an existing identity" item. const ADD_IDENTITY_LOAD_COMMAND: &str = "IdentityHubAddLoad"; -/// `AppAction::Custom` command emitted by the Power-user test-identities item. -const CREATE_TEST_IDENTITIES_COMMAND: &str = "IdentityHubCreateTestIdentities"; /// Items of the hub's "Add" menu, shared by the top-bar dropdown and the /// picker's "Add a new identity" card so both offer the same choices as the /// identity pill's dropdown. Each item emits an `AppAction::Custom` command the /// hub maps back via [`add_identity_command_effect`]. -pub(crate) fn add_identity_menu_items(role: UserRole) -> Vec { - let mut items = vec![ +pub(crate) fn add_identity_menu_items() -> Vec { + vec![ ToolbarMenuItem { label: "Create a new identity", action: DesiredAppAction::Custom(ADD_IDENTITY_CREATE_COMMAND.into()), @@ -100,16 +94,7 @@ pub(crate) fn add_identity_menu_items(role: UserRole) -> Vec { enabled: true, tooltip: "Load an identity you already own, by its ID or from your wallet.", }, - ]; - if role.at_least(UserRole::Power) { - items.push(ToolbarMenuItem { - label: "Create multiple test identities", - action: DesiredAppAction::Custom(CREATE_TEST_IDENTITIES_COMMAND.into()), - enabled: true, - tooltip: "Create identities for testing.", - }); - } - items + ] } /// Map an action produced by an [`add_identity_menu_items`] entry to the hub @@ -121,7 +106,6 @@ pub(crate) fn add_identity_command_effect(action: &AppAction) -> Option Some(BreadcrumbEffect::AddIdentityCreate), ADD_IDENTITY_LOAD_COMMAND => Some(BreadcrumbEffect::AddIdentityLoad), - CREATE_TEST_IDENTITIES_COMMAND => Some(BreadcrumbEffect::CreateTestIdentities), _ => None, } } @@ -182,39 +166,19 @@ mod tests { map_effect(GlobalNavEffect::AddWallet), BreadcrumbEffect::AddWallet ); - assert_eq!( - map_effect(GlobalNavEffect::CreateTestIdentities), - BreadcrumbEffect::CreateTestIdentities - ); } - /// Everyday users get create + load; the dev bulk-create entry is gated - /// to Power users and up, matching the identity pill's dropdown. + /// The Add menu offers exactly create and load, both enabled, matching + /// the identity pill's dropdown. #[test] - fn add_menu_items_follow_the_user_role() { - let labels = |role| { - add_identity_menu_items(role) - .into_iter() - .map(|item| item.label) - .collect::>() - }; + fn add_menu_offers_create_and_load() { + let items = add_identity_menu_items(); + let labels: Vec<_> = items.iter().map(|item| item.label).collect(); assert_eq!( - labels(UserRole::Everyday), + labels, ["Create a new identity", "Load an existing identity"] ); - assert_eq!( - labels(UserRole::Power), - [ - "Create a new identity", - "Load an existing identity", - "Create multiple test identities" - ] - ); - assert!( - add_identity_menu_items(UserRole::Developer) - .iter() - .all(|item| item.enabled) - ); + assert!(items.iter().all(|item| item.enabled)); } /// Every add-menu item maps back to its hub effect; unrelated actions do not. @@ -224,7 +188,7 @@ mod tests { DesiredAppAction::Custom(command) => AppAction::Custom(command.clone()), other => panic!("unexpected menu action {other:?}"), }; - let effects: Vec<_> = add_identity_menu_items(UserRole::Developer) + let effects: Vec<_> = add_identity_menu_items() .iter() .map(|item| add_identity_command_effect(&ctx_free_action(item))) .collect(); @@ -233,7 +197,6 @@ mod tests { [ Some(BreadcrumbEffect::AddIdentityCreate), Some(BreadcrumbEffect::AddIdentityLoad), - Some(BreadcrumbEffect::CreateTestIdentities), ] ); assert_eq!( diff --git a/src/ui/identity/hub_screen.rs b/src/ui/identity/hub_screen.rs index bc27882a5..a65353072 100644 --- a/src/ui/identity/hub_screen.rs +++ b/src/ui/identity/hub_screen.rs @@ -374,9 +374,7 @@ impl IdentityHubScreen { BreadcrumbEffect::AddWallet => { AppAction::SetMainScreen(RootScreenType::RootScreenWalletsBalances) } - // The bulk-create flow is not wired yet; route to the single-create - // screen so the dev entry is functional in the interim. - BreadcrumbEffect::AddIdentityCreate | BreadcrumbEffect::CreateTestIdentities => { + BreadcrumbEffect::AddIdentityCreate => { AppAction::AddScreen(crate::ui::Screen::AddNewIdentityScreen( super::add_new_identity_screen::AddNewIdentityScreen::new_with_wallet( &self.app_context, @@ -428,9 +426,7 @@ impl ScreenLike for IdentityHubScreen { }, vec![( "Add", - DesiredAppAction::Menu(breadcrumb_switcher::add_identity_menu_items( - self.app_context.user_role(), - )), + DesiredAppAction::Menu(breadcrumb_switcher::add_identity_menu_items()), )], ); diff --git a/src/ui/identity/picker.rs b/src/ui/identity/picker.rs index 5f9354e90..2d144e1e2 100644 --- a/src/ui/identity/picker.rs +++ b/src/ui/identity/picker.rs @@ -163,7 +163,7 @@ fn show_add_menu( .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) .frame(egui::Frame::popup(ui.style()).fill(DashColors::popup_fill(dark_mode))) .show(|ui| { - for item in add_identity_menu_items(app_context.user_role()) { + for item in add_identity_menu_items() { let clicked = ui .add_enabled_ui(item.enabled, |ui| { ComponentStyles::add_button(ui, egui::Button::new(item.label)) diff --git a/tests/kittest/identity_hub_switcher.rs b/tests/kittest/identity_hub_switcher.rs index 99f167cdd..49018fa0e 100644 --- a/tests/kittest/identity_hub_switcher.rs +++ b/tests/kittest/identity_hub_switcher.rs @@ -567,6 +567,11 @@ fn ui_polish_many_identities_picker_scroll_reaches_add_card() { ); add.click(); harness.run_steps(5); + // The card opens its create/load menu; picking an item opens the screen. + harness + .get_by_role_and_label(egui::accesskit::Role::Button, "Load an existing identity") + .click(); + harness.run_steps(5); assert!( !harness.state().screen_stack.is_empty(), "Add card must be clickable after scrolling" From 09d7fdbf071c4e89994512652ce1a29e19e5371c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:42:26 +0000 Subject: [PATCH 4/5] fix(ui): share action menus and cover identity wallet preselection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clarify the identity menu documentation, consolidate four popup renderers, and assert selected-wallet preservation through toolbar and picker creation. Co-Authored-By: OpenAI Codex 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- src/ui/components/README.md | 3 + src/ui/components/action_menu.rs | 38 +++++++ src/ui/components/mod.rs | 1 + src/ui/components/top_panel.rs | 144 ++++++++++--------------- src/ui/identity/breadcrumb_switcher.rs | 6 +- src/ui/identity/picker.rs | 43 +++----- tests/kittest/identity_hub_switcher.rs | 81 ++++++++++++++ 7 files changed, 195 insertions(+), 121 deletions(-) create mode 100644 src/ui/components/action_menu.rs diff --git a/src/ui/components/README.md b/src/ui/components/README.md index 533742b12..194aab695 100644 --- a/src/ui/components/README.md +++ b/src/ui/components/README.md @@ -28,6 +28,9 @@ Concise catalog of all reusable UI components. Consult before creating new UI el ## Display Components +`action_menu::show_action_menu()` renders anchored action popups for toolbar menus +and picker cards, including enabled state, tooltips, theme colors, and dismissal. + | Component | File | DomainType | Description | |-----------|------|------------|-------------| | `Avatar` | `avatar.rs` | N/A (display) | DashPay contact/profile avatar from a URL. Renders image / spinner / `👤` fallback, decoding + uploading the texture on the UI thread. Backed by `ui/state/avatar_cache.rs` (`AvatarCache`), which fetches off-frame via `DashPayTask::FetchAvatar`. `show(ui, &mut AvatarCache)` returns `AvatarResponse { fetch, clicked }`; the caller dispatches `fetch`. Builders: `corner_radius`, `clickable(tooltip)`. | diff --git a/src/ui/components/action_menu.rs b/src/ui/components/action_menu.rs new file mode 100644 index 000000000..deab89c33 --- /dev/null +++ b/src/ui/components/action_menu.rs @@ -0,0 +1,38 @@ +use crate::app::{AppAction, DesiredAppAction}; +use crate::context::AppContext; +use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; +use egui::{Popup, Ui}; +use std::sync::Arc; + +/// Render a themed action popup and return the selected item's action. +pub(crate) fn show_action_menu<'a>( + ui: &Ui, + app_context: &Arc, + popup: Popup<'_>, + items: impl IntoIterator, +) -> AppAction { + let mut action = AppAction::None; + popup + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .frame(egui::Frame::popup(ui.style()).fill(DashColors::popup_fill(ui.visuals().dark_mode))) + .show(|ui| { + ui.set_min_width(150.0); + for (label, desired_action, enabled, tooltip) in items { + let mut response = ui + .add_enabled_ui(enabled, |ui| { + ComponentStyles::add_button(ui, egui::Button::new(label)) + }) + .inner; + if !tooltip.is_empty() { + response = response + .clickable_tooltip(tooltip) + .disabled_tooltip(tooltip); + } + if response.clicked() { + action = desired_action.create_action(app_context); + ui.close(); + } + } + }); + action +} diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index e2d2d4ea0..ccdc4d9d7 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod action_menu; pub mod address_input; pub mod alias_input; pub mod amount_input; diff --git a/src/ui/components/top_panel.rs b/src/ui/components/top_panel.rs index abf7731e7..cd35b1561 100644 --- a/src/ui/components/top_panel.rs +++ b/src/ui/components/top_panel.rs @@ -1,6 +1,7 @@ use crate::app::{AppAction, DesiredAppAction}; use crate::context::AppContext; use crate::context::connection_status::OverallConnectionState; +use crate::ui::components::action_menu::show_action_menu; use crate::ui::components::global_nav_switcher::{self, GlobalNavEffect}; use crate::ui::state::global_nav::{PageNavSpec, PillConsumption}; use crate::ui::state::hub_selection::HubSelection; @@ -228,32 +229,22 @@ fn render_top_island( ); let popup_id = ui.make_persistent_id("docs_popup"); - let dark_mode = ui.style().visuals.dark_mode; - egui::Popup::new( - popup_id, - ui.ctx().clone(), - &resp, - resp.layer_id, - ) - .open_memory( - resp.clicked().then_some(egui::SetOpenCommand::Toggle), - ) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .frame( - egui::Frame::popup(ui.style()) - .fill(DashColors::popup_fill(dark_mode)), - ) - .show(|ui| { - ui.set_min_width(150.0); - for (text, da) in doc_actions { - let btn = egui::Button::new(text) - .min_size(egui::vec2(ui.available_width(), 0.0)); - if ComponentStyles::add_button(ui, btn).clicked() { - action = da.create_action(app_context); - ui.close(); - } - } - }); + action |= show_action_menu( + ui, + app_context, + egui::Popup::new( + popup_id, + ui.ctx().clone(), + &resp, + resp.layer_id, + ) + .open_memory( + resp.clicked().then_some(egui::SetOpenCommand::Toggle), + ), + doc_actions + .iter() + .map(|(label, action)| (*label, action, true, "")), + ); } // Grouped Contracts menu @@ -267,32 +258,22 @@ fn render_top_island( network_accent, ); - let dark_mode = ui.style().visuals.dark_mode; - egui::Popup::new( - popup_id, - ui.ctx().clone(), - &resp, - resp.layer_id, - ) - .open_memory( - resp.clicked().then_some(egui::SetOpenCommand::Toggle), - ) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .frame( - egui::Frame::popup(ui.style()) - .fill(DashColors::popup_fill(dark_mode)), - ) - .show(|ui| { - ui.set_min_width(150.0); - for (text, ca) in contract_actions { - let btn = egui::Button::new(text) - .min_size(egui::vec2(ui.available_width(), 0.0)); - if ComponentStyles::add_button(ui, btn).clicked() { - action = ca.create_action(app_context); - ui.close(); - } - } - }); + action |= show_action_menu( + ui, + app_context, + egui::Popup::new( + popup_id, + ui.ctx().clone(), + &resp, + resp.layer_id, + ) + .open_memory( + resp.clicked().then_some(egui::SetOpenCommand::Toggle), + ), + contract_actions + .iter() + .map(|(label, action)| (*label, action, true, "")), + ); } // Render other buttons normally @@ -309,44 +290,29 @@ fn render_top_island( network_accent, ); if let DesiredAppAction::Menu(items) = btn_act { - egui::Popup::new( - ui.make_persistent_id(text), - ui.ctx().clone(), - &response, - response.layer_id, - ) - .open_memory( - response - .clicked() - .then_some(egui::SetOpenCommand::Toggle), - ) - .close_behavior( - egui::PopupCloseBehavior::CloseOnClickOutside, - ) - .frame( - egui::Frame::popup(ui.style()).fill( - DashColors::popup_fill(ui.visuals().dark_mode), + action |= show_action_menu( + ui, + app_context, + egui::Popup::new( + ui.make_persistent_id(text), + ui.ctx().clone(), + &response, + response.layer_id, + ) + .open_memory( + response + .clicked() + .then_some(egui::SetOpenCommand::Toggle), ), - ) - .show(|ui| { - for item in items { - let clicked = ui - .add_enabled_ui(item.enabled, |ui| { - ComponentStyles::add_button( - ui, - egui::Button::new(item.label), - ) - }) - .inner - .clickable_tooltip(item.tooltip) - .disabled_tooltip(item.tooltip) - .clicked(); - if clicked { - action = item.action.create_action(app_context); - ui.close(); - } - } - }); + items.iter().map(|item| { + ( + item.label, + &item.action, + item.enabled, + item.tooltip, + ) + }), + ); } else if response.clicked() { action = btn_act.create_action(app_context); } diff --git a/src/ui/identity/breadcrumb_switcher.rs b/src/ui/identity/breadcrumb_switcher.rs index befd88c43..2ae49ed35 100644 --- a/src/ui/identity/breadcrumb_switcher.rs +++ b/src/ui/identity/breadcrumb_switcher.rs @@ -76,10 +76,8 @@ const ADD_IDENTITY_CREATE_COMMAND: &str = "IdentityHubAddCreate"; /// `AppAction::Custom` command emitted by the "Load an existing identity" item. const ADD_IDENTITY_LOAD_COMMAND: &str = "IdentityHubAddLoad"; -/// Items of the hub's "Add" menu, shared by the top-bar dropdown and the -/// picker's "Add a new identity" card so both offer the same choices as the -/// identity pill's dropdown. Each item emits an `AppAction::Custom` command the -/// hub maps back via [`add_identity_command_effect`]. +/// Items shared by the hub toolbar and picker card; the identity pill defines its own items. +/// Commands are routed by [`add_identity_command_effect`]. pub(crate) fn add_identity_menu_items() -> Vec { vec![ ToolbarMenuItem { diff --git a/src/ui/identity/picker.rs b/src/ui/identity/picker.rs index 2d144e1e2..4bd2e6d2a 100644 --- a/src/ui/identity/picker.rs +++ b/src/ui/identity/picker.rs @@ -19,7 +19,8 @@ use super::identity_picker_card::{CARD_MIN_WIDTH, IdentityPickerCard}; use crate::app::AppAction; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; -use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; +use crate::ui::components::action_menu::show_action_menu; +use crate::ui::theme::DashColors; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use eframe::egui::{RichText, Ui}; @@ -151,34 +152,20 @@ fn show_add_menu( app_context: &Arc, card: &super::identity_picker_add_card::IdentityPickerAddCardResponse, ) -> AppAction { - let mut action = AppAction::None; - let dark_mode = ui.visuals().dark_mode; - egui::Popup::new( - egui::Id::new("identity_picker_add_menu"), - ui.ctx().clone(), - card.rect, - ui.layer_id(), + show_action_menu( + ui, + app_context, + egui::Popup::new( + egui::Id::new("identity_picker_add_menu"), + ui.ctx().clone(), + card.rect, + ui.layer_id(), + ) + .open_memory(card.add_requested.then_some(egui::SetOpenCommand::Toggle)), + add_identity_menu_items() + .iter() + .map(|item| (item.label, &item.action, item.enabled, item.tooltip)), ) - .open_memory(card.add_requested.then_some(egui::SetOpenCommand::Toggle)) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .frame(egui::Frame::popup(ui.style()).fill(DashColors::popup_fill(dark_mode))) - .show(|ui| { - for item in add_identity_menu_items() { - let clicked = ui - .add_enabled_ui(item.enabled, |ui| { - ComponentStyles::add_button(ui, egui::Button::new(item.label)) - }) - .inner - .clickable_tooltip(item.tooltip) - .disabled_tooltip(item.tooltip) - .clicked(); - if clicked { - action = item.action.create_action(app_context); - ui.close(); - } - } - }); - action } /// Compute how many cards fit per row at the given available width. Matches diff --git a/tests/kittest/identity_hub_switcher.rs b/tests/kittest/identity_hub_switcher.rs index 49018fa0e..7df520789 100644 --- a/tests/kittest/identity_hub_switcher.rs +++ b/tests/kittest/identity_hub_switcher.rs @@ -1177,3 +1177,84 @@ fn picker_top_bar_add_menu_creates_or_loads_an_identity() { ); }); } + +#[test] +fn picker_create_entries_preserve_selected_wallet() { + with_identity_hub(|mut harness, app_context| { + use dash_evo_tool::model::wallet::Wallet; + use dash_evo_tool::model::wallet::birth_height::WalletOrigin; + use dash_evo_tool::ui::Screen; + use zeroize::Zeroize; + + let mut wallets = Vec::new(); + for alias in ["First candidate", "Second candidate"] { + let mut seed: [u8; 64] = rand::random(); + let wallet = + Wallet::new_from_seed(seed, app_context.network(), Some(alias.into()), None) + .expect("wallet fixture"); + let hash = wallet.seed_hash(); + app_context + .register_wallet(wallet, &seed, WalletOrigin::Imported) + .expect("register wallet fixture"); + seed.zeroize(); + wallets.push((hash, alias)); + } + wallets.sort_by_key(|(hash, _)| *hash); + let (selected_hash, selected_alias) = wallets[1]; + assert_ne!( + app_context + .wallet_context() + .first_hd() + .unwrap() + .read() + .unwrap() + .seed_hash(), + selected_hash + ); + for byte in [0xC3, 0xC4] { + let id = seed_identity(&app_context, byte, &format!("Owned identity {byte}")); + let identity = app_context + .load_local_user_identities() + .unwrap() + .into_iter() + .find(|identity| identity.identity.id() == id) + .unwrap(); + app_context + .insert_local_qualified_identity(&identity, &Some((selected_hash, 0))) + .unwrap(); + } + app_context.set_selected_hd_wallet(Some(wallets[0].0)); + harness.run_steps(5); + harness + .get_by_role_and_label(egui::accesskit::Role::Link, wallets[0].1) + .click(); + harness.run_steps(3); + harness + .get_by_label(&format!("💼 {selected_alias}")) + .click(); + harness.run_steps(5); + + for entry in ["Add ▾", "Add a new identity"] { + assert_eq!(app_context.selected_wallet_hash(), Some(selected_hash)); + assert!(harness.query_by_label(PICKER_HEADING).is_some()); + harness + .get_by_role_and_label(egui::accesskit::Role::Button, entry) + .click(); + harness.run_steps(3); + harness.get_by_label("Create a new identity").click(); + harness.run_steps(3); + assert!(matches!( + harness.state().screen_stack.last(), + Some(Screen::AddNewIdentityScreen(_)) + )); + assert!( + harness + .query_by_value(&format!("{selected_alias} — 0 DASH")) + .is_some(), + "{entry} must preselect the chosen wallet in the creation form" + ); + harness.state_mut().screen_stack.clear(); + harness.run_steps(3); + } + }); +} From e859870b0df2850c4fdce22d00d8f1ded5290a95 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:42:25 +0000 Subject: [PATCH 5/5] fix(ui): align dropdown rows and enlarge menu indicators --- src/ui/components/action_menu.rs | 17 ++++++++++++++-- src/ui/components/top_panel.rs | 23 +++++++++++---------- src/ui/theme.rs | 34 +++++++++++++++++++++++++++++++- 3 files changed, 61 insertions(+), 13 deletions(-) diff --git a/src/ui/components/action_menu.rs b/src/ui/components/action_menu.rs index deab89c33..e70073816 100644 --- a/src/ui/components/action_menu.rs +++ b/src/ui/components/action_menu.rs @@ -16,11 +16,24 @@ pub(crate) fn show_action_menu<'a>( .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) .frame(egui::Frame::popup(ui.style()).fill(DashColors::popup_fill(ui.visuals().dark_mode))) .show(|ui| { - ui.set_min_width(150.0); + let items: Vec<_> = items.into_iter().collect(); + let width = items.iter().fold(150.0_f32, |width, (label, ..)| { + let galley = egui::WidgetText::from(*label).into_galley( + ui, + Some(egui::TextWrapMode::Extend), + f32::INFINITY, + egui::FontSelection::Style(egui::TextStyle::Button), + ); + width.max((galley.size().x + 2.0 * ui.spacing().button_padding.x).ceil()) + }); + ui.set_width(width); for (label, desired_action, enabled, tooltip) in items { let mut response = ui .add_enabled_ui(enabled, |ui| { - ComponentStyles::add_button(ui, egui::Button::new(label)) + ComponentStyles::add_button( + ui, + egui::Button::new(label).min_size(egui::vec2(width, 0.0)), + ) }) .inner; if !tooltip.is_empty() { diff --git a/src/ui/components/top_panel.rs b/src/ui/components/top_panel.rs index cd35b1561..c86d6667e 100644 --- a/src/ui/components/top_panel.rs +++ b/src/ui/components/top_panel.rs @@ -222,7 +222,7 @@ fn render_top_island( if !doc_actions.is_empty() { ui.add_space(3.0); - let resp = ComponentStyles::add_toolbar_button( + let resp = ComponentStyles::add_toolbar_menu_button( ui, "Documents", network_accent, @@ -252,7 +252,7 @@ fn render_top_island( ui.add_space(3.0); let popup_id = ui.auto_id_with("contracts_popup"); - let resp = ComponentStyles::add_toolbar_button( + let resp = ComponentStyles::add_toolbar_menu_button( ui, "Contracts", network_accent, @@ -279,16 +279,19 @@ fn render_top_island( // Render other buttons normally for (text, btn_act) in other_actions.into_iter().rev() { ui.add_space(3.0); - let label = if matches!(btn_act, DesiredAppAction::Menu(_)) { - format!("{text} ▾") + let response = if matches!(btn_act, DesiredAppAction::Menu(_)) { + ComponentStyles::add_toolbar_menu_button( + ui, + text, + network_accent, + ) } else { - text.to_owned() + ComponentStyles::add_toolbar_button( + ui, + text, + network_accent, + ) }; - let response = ComponentStyles::add_toolbar_button( - ui, - &label, - network_accent, - ); if let DesiredAppAction::Menu(items) = btn_act { action |= show_action_menu( ui, diff --git a/src/ui/theme.rs b/src/ui/theme.rs index e84a7c155..df7ccc0d4 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -1144,7 +1144,12 @@ impl ComponentStyles { fn styled_label(label: impl Into, color: Color32, strong: bool) -> WidgetText { let rt = match label.into() { WidgetText::RichText(rt) => rt.as_ref().clone(), - // LayoutJob/Galley variants are not used by any callsite. + WidgetText::LayoutJob(mut job) => { + for section in &mut std::sync::Arc::make_mut(&mut job).sections { + section.format.color = color; + } + return WidgetText::LayoutJob(job); + } other => RichText::new(other.text().to_string()), }; let rt = if strong { rt.strong() } else { rt }; @@ -1239,6 +1244,33 @@ impl ComponentStyles { .min_size(Self::TOOLBAR_BUTTON_MIN_SIZE) } + /// Add a toolbar menu button with a clearly visible dropdown indicator. + pub fn add_toolbar_menu_button( + ui: &mut egui::Ui, + label: &str, + fill: egui::Color32, + ) -> egui::Response { + let font = egui::TextStyle::Button.resolve(ui.style()); + let mut job = egui::text::LayoutJob::default(); + job.append( + label, + 0.0, + egui::TextFormat::simple(font.clone(), DashColors::WHITE), + ); + job.append( + " ▾", + 0.0, + egui::TextFormat { + font_id: egui::FontId::new(font.size * 1.5, font.family), + color: DashColors::WHITE, + line_height: Some(font.size), + valign: egui::Align::Center, + ..Default::default() + }, + ); + Self::add_toolbar_button(ui, job, fill) + } + /// Add a toolbar button to the UI with pointer cursor on hover. /// /// The label is centered; see [`Self::add_centered_button`].