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 1f51b9def..52485c03f 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -585,6 +585,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/app.rs b/src/app.rs index 4b98b46c7..51092d509 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/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..e70073816 --- /dev/null +++ b/src/ui/components/action_menu.rs @@ -0,0 +1,51 @@ +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| { + 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).min_size(egui::vec2(width, 0.0)), + ) + }) + .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/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/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 d0f91b1d5..c86d6667e 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; @@ -221,39 +222,29 @@ 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, ); 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 @@ -261,46 +252,71 @@ 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, ); - 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 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 response = if matches!(btn_act, DesiredAppAction::Menu(_)) { + ComponentStyles::add_toolbar_menu_button( + ui, + text, + network_accent, + ) + } else { + ComponentStyles::add_toolbar_button( + ui, + text, + network_accent, + ) + }; + if let DesiredAppAction::Menu(items) = btn_act { + 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), + ), + items.iter().map(|item| { + ( + item.label, + &item.action, + item.enabled, + item.tooltip, + ) + }), + ); + } else if response.clicked() { action = btn_act.create_action(app_context); } } @@ -406,7 +422,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 => { @@ -479,6 +495,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. diff --git a/src/ui/identity/breadcrumb_switcher.rs b/src/ui/identity/breadcrumb_switcher.rs index e2eb47842..2ae49ed35 100644 --- a/src/ui/identity/breadcrumb_switcher.rs +++ b/src/ui/identity/breadcrumb_switcher.rs @@ -10,6 +10,7 @@ //! //! [`global_nav_switcher`]: crate::ui::components::global_nav_switcher +use crate::app::{AppAction, DesiredAppAction, ToolbarMenuItem}; use crate::context::AppContext; use crate::ui::RootScreenType; use crate::ui::components::global_nav_switcher::{self, GlobalNavEffect}; @@ -39,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 @@ -69,7 +68,43 @@ fn map_effect(effect: GlobalNavEffect) -> BreadcrumbEffect { GlobalNavEffect::AddWallet => BreadcrumbEffect::AddWallet, GlobalNavEffect::AddIdentityCreate => BreadcrumbEffect::AddIdentityCreate, GlobalNavEffect::AddIdentityLoad => BreadcrumbEffect::AddIdentityLoad, - GlobalNavEffect::CreateTestIdentities => BreadcrumbEffect::CreateTestIdentities, + } +} + +/// `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"; + +/// 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 { + 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.", + }, + ] +} + +/// 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), + _ => None, } } @@ -129,9 +164,43 @@ mod tests { map_effect(GlobalNavEffect::AddWallet), BreadcrumbEffect::AddWallet ); + } + + /// The Add menu offers exactly create and load, both enabled, matching + /// the identity pill's dropdown. + #[test] + 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, + ["Create a new identity", "Load an existing identity"] + ); + assert!(items.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() + .iter() + .map(|item| add_identity_command_effect(&ctx_free_action(item))) + .collect(); + assert_eq!( + effects, + [ + Some(BreadcrumbEffect::AddIdentityCreate), + Some(BreadcrumbEffect::AddIdentityLoad), + ] + ); assert_eq!( - map_effect(GlobalNavEffect::CreateTestIdentities), - BreadcrumbEffect::CreateTestIdentities + 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..a65353072 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; @@ -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, @@ -426,7 +424,10 @@ 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()), + )], ); action |= add_left_panel(ui, &self.app_context, RootScreenType::RootScreenIdentityHub); @@ -564,6 +565,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..4bd2e6d2a 100644 --- a/src/ui/identity/picker.rs +++ b/src/ui/identity/picker.rs @@ -5,20 +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::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; @@ -35,8 +36,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 +124,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 +145,29 @@ 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 { + 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)), + ) +} + /// 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 +222,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/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`]. diff --git a/tests/kittest/identity_hub_switcher.rs b/tests/kittest/identity_hub_switcher.rs index 203f1a260..7df520789 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" @@ -1126,3 +1131,130 @@ 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" + ); + }); +} + +#[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); + } + }); +}