Skip to content
Merged
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions docs/user-stories.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ToolbarMenuItem>),
None,
Refresh,
AddScreenType(Box<ScreenType>),
Expand All @@ -1283,7 +1293,7 @@ pub enum DesiredAppAction {
impl DesiredAppAction {
pub fn create_action(&self, app_context: &Arc<AppContext>) -> 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) => {
Expand Down
3 changes: 3 additions & 0 deletions src/ui/components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`. |
Expand Down
38 changes: 38 additions & 0 deletions src/ui/components/action_menu.rs
Original file line number Diff line number Diff line change
@@ -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<AppContext>,
popup: Popup<'_>,
items: impl IntoIterator<Item = (&'a str, &'a DesiredAppAction, bool, &'a str)>,
) -> 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))
})
Comment thread
lklimek marked this conversation as resolved.
.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
}
9 changes: 0 additions & 9 deletions src/ui/components/global_nav_switcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
}
});
});
}
Expand Down
1 change: 1 addition & 0 deletions src/ui/components/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub(crate) mod action_menu;
pub mod address_input;
pub mod alias_input;
pub mod amount_input;
Expand Down
189 changes: 133 additions & 56 deletions src/ui/components/top_panel.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -267,40 +258,62 @@ 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
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 {
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);
}
}
Expand Down Expand Up @@ -406,7 +419,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 => {
Expand Down Expand Up @@ -479,6 +492,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.
Expand Down
Loading
Loading