From f948c9b197feb4d4a2b582b8fe9c66b39e91854a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:32:19 +0100 Subject: [PATCH 01/19] docs: add unified message display design documents Add UX specification, technical architecture, and HTML mockup for the MessageBanner component that will replace the ~50 ad-hoc error/message display implementations across screens with a single reusable component. Key design decisions: - Per-screen MessageBanner with show()/set_message() API - All colors via DashColors (zero hardcoded Color32 values) - 4 severity levels: Error, Warning, Success, Info - Auto-dismiss for Success/Info (5s), persistent for Error/Warning - Follows Component Design Pattern conventions (private fields, builder, show) - No changes to BackendTask/TaskResult/AppState architecture Co-Authored-By: Claude Opus 4.6 --- .../architecture.md | 746 ++++++++++++++++++ .../message-banner-mockup.html | 445 +++++++++++ .../message-banner-ux-spec.md | 271 +++++++ 3 files changed, 1462 insertions(+) create mode 100644 docs/ai-design/2026-02-17-unified-messages/architecture.md create mode 100644 docs/ai-design/2026-02-17-unified-messages/message-banner-mockup.html create mode 100644 docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md diff --git a/docs/ai-design/2026-02-17-unified-messages/architecture.md b/docs/ai-design/2026-02-17-unified-messages/architecture.md new file mode 100644 index 000000000..c9afa675e --- /dev/null +++ b/docs/ai-design/2026-02-17-unified-messages/architecture.md @@ -0,0 +1,746 @@ +# MessageBanner Component -- Technical Architecture + +## 0. Current State Analysis + +### Overview + +The codebase has **~50 screens** (structs implementing `ScreenLike`) with no unified message rendering component. There are **4 distinct rendering patterns**, **8+ different error colors**, and **two competing `MessageType` enums**. Each screen independently manages its own message fields and rendering. + +### Rendering Style Taxonomy + +| Pattern | Description | Used By | +|---------|-------------|---------| +| **A - Framed Banner** | `Frame` with `fill(color.gamma_multiply(0.1))`, border stroke, RichText label, "Dismiss" button | ~8 screens | +| **B - Timed Badge** | `(String, MessageType, DateTime)` tuple, rendered with countdown timer (auto-dismiss ~10s) | ~8 screens | +| **C - Status Enum** | Error stored in a status enum variant (e.g., `TransferCreditsStatus::ErrorMessage`), rendered inline via `colored_label` | ~8 screens | +| **D - Bare colored_label** | Simple `ui.colored_label(Color32::X, message)` inline, no frame, no dismiss | ~20+ screens | + +### Error Color Inconsistency + +| Color Value | Usage Count | Screens | +|---|---|---| +| `Color32::from_rgb(255, 100, 100)` | ~8 screens | AddNewIdentityScreen, TopUpIdentityScreen, WalletsBalancesScreen, WalletSendScreen, PlatformInfoScreen, AddressBalanceScreen, KeyInfoScreen, ImportMnemonicScreen | +| `Color32::DARK_RED` | ~10 screens | WithdrawalScreen, TransferScreen, RegisterDpnsNameScreen, IdentitiesScreen, DPNSScreen, ContactsList, ContactRequests, AddContactScreen, SendPaymentScreen, SetTokenPriceScreen | +| `Color32::RED` | ~5 screens | FreezeTokensScreen, UnfreezeTokensScreen, PauseTokensScreen, ResumeTokensScreen, ClaimTokensScreen | +| `DashColors::error_color(dark_mode)` | ~7 screens | MintTokensScreen, BurnTokensScreen, DestroyFrozenFundsScreen, ProfileSearchScreen, ContactProfileViewerScreen, QRCodeGeneratorScreen, QRScannerScreen | +| `DashColors::ERROR` (`from_rgb(235,87,87)`) | ~1 screen | ContactInfoEditorScreen | +| `Color32::from_rgb(220, 80, 80)` | ~1 screen | WalletUnlockPopup | + +### Per-Screen Catalog + +#### Identities + +| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | +|---|---|---|---|---|---|---| +| `IdentitiesScreen` | `identities/identities_screen.rs` | `backend_message: Option<(String, MessageType, DateTime)>` | Pattern B (timed, 10s auto-dismiss with countdown) | Custom: sets `backend_message` | Custom: `RefreshedIdentity` → Success | Shows spinner during refresh; Error=`DARK_RED`, Success=`DARK_GREEN` | +| `AddNewIdentityScreen` | `identities/add_new_identity_screen/mod.rs` | `error_message: Option` | Pattern A (framed banner + Dismiss) | Custom: sets `error_message` | Default | Prefix: `"Error registering identity: {}"`. Color: `from_rgb(255,100,100)` | +| `AddExistingIdentityScreen` | `identities/add_existing_identity_screen.rs` | `error_message`, `backend_message`, `success_message` | Pattern A for errors | Custom: routes to 3 fields | Custom: `LoadedIdentity`, `Message` | **3 separate message fields**; `backend_message` = progress indicator | +| `TopUpIdentityScreen` | `identities/top_up_identity_screen/mod.rs` | `error_message: Option` | Pattern A (framed banner + Dismiss) | Custom: sets `error_message`; resets step state on Error | Default | Also resets `WalletFundedScreenStep` on error | +| `WithdrawalScreen` | `identities/withdraw_screen.rs` | `error_message`, `withdraw_from_identity_status` enum | Pattern C (status enum) | Custom: sets status enum | Custom: `WithdrewFromIdentity` → Complete | Status enum is primary error carrier | +| `TransferScreen` | `identities/transfer_screen.rs` | `error_message`, `transfer_credits_status` enum | Pattern C (status enum) | Custom: sets **both** enum AND `error_message` | Custom: `TransferredCredits` → Complete | **Redundant dual-field storage** | +| `RegisterDpnsNameScreen` | `identities/register_dpns_name_screen.rs` | `error_message: Option` | Pattern D (bare colored_label) | Custom: sets `error_message` | Default | Color: `DARK_RED` | + +#### Identity Keys + +| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | +|---|---|---|---|---|---|---| +| `KeysScreen` | `identities/keys/keys_screen.rs` | None | None | Default no-op | Default | Minimal screen, no messages | +| `KeyInfoScreen` | `identities/keys/key_info_screen.rs` | `error_message`, `sign_error_message` | Pattern A-like for `error_message`; Pattern D for `sign_error_message` | Default no-op | Default | **2 error fields** for different operations; no `display_message` override | +| `AddKeyScreen` | `identities/keys/add_key_screen.rs` | `error_message`, `add_key_status` enum | Pattern C (status enum) | Custom: sets `add_key_status` | Custom: `AddedKeyToIdentity` → Complete | | + +#### DPNS + +| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | +|---|---|---|---|---|---|---| +| `DPNSScreen` | `dpns/dpns_contested_names_screen.rs` | `message: Option<(String, MessageType, DateTime)>`, `bulk_schedule_message` | Pattern B (timed) + bulk block | Custom: sets `message` | Custom: extensive vote handling | **2 message slots**; bulk message uses emoji icons (❌/🎉) | + +#### Wallets + +| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | +|---|---|---|---|---|---|---| +| `WalletsBalancesScreen` | `wallets/wallets_screen/mod.rs` | `message: Option<(String, MessageType, DateTime)>`, `sk_error_message` | Pattern A (framed banner + Dismiss) | Custom: routes errors to fund dialog if processing | Custom: wallet ops | **Dialog interception**: fund-platform dialog captures errors | +| `AddNewWalletScreen` | `wallets/add_new_wallet_screen.rs` | `error: Option` | **Modal Window** (`egui::Window::new("Error")`) | Default no-op | Default | Only screen using modal error popup | +| `ImportMnemonicScreen` | `wallets/import_mnemonic_screen.rs` | `error: Option` | Pattern D (bare colored_label) | Default no-op | Default | No `display_message` override | +| `WalletSendScreen` | `wallets/send_screen.rs` | `send_status: SendStatus`, `error_message` | Pattern A (framed banner) + full success screen | Custom: sets `send_status` enum | Custom: `WalletPayment`, etc. | Status enum drives display; success shows full-page 🎉 view | +| `SingleKeyWalletSendScreen` | `wallets/single_key_send_screen.rs` | `message: Option<(String, MessageType, DateTime)>`, `error_message` | Pattern B (timed banner) | Custom: sets `message` | Custom: `WalletPayment` | 2 fields | +| `CreateAssetLockScreen` | `wallets/create_asset_lock_screen.rs` | `message: Option<(String, MessageType, DateTime)>`, `error_message` | Pattern B (timed banner) | Custom: sets `message` | Custom: asset lock types | Helper methods `set_error_message()`/`error_message()` | +| `AssetLockDetailScreen` | `wallets/asset_lock_detail_screen.rs` | `message: Option<(String, MessageType, DateTime)>`, `error_message` | Pattern B (timed banner) | Custom: sets `message` | Custom: asset lock retrieval | Helper methods | + +#### Contracts / Documents + +| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | +|---|---|---|---|---|---|---| +| `DocumentQueryScreen` | `contracts_documents/contracts_documents_screen.rs` | `error_message: Option<(String, MessageType, DateTime)>` | Pattern D (colored_label + timestamp expiry) | Custom: **only handles** `"Error fetching documents"` | Custom: Documents/PageDocuments | **Filters messages by text content** | +| `AddContractsScreen` | `contracts_documents/add_contracts_screen.rs` | `add_contracts_status` enum | Pattern C (status enum) | Custom: sets status enum | Custom: `FetchedContracts` | | +| `RegisterDataContractScreen` | `contracts_documents/register_contract_screen.rs` | `error_message`, `broadcast_status` enum | Pattern C + Pattern A | Custom: routes by text pattern | Custom: `RegisteredContract` | **Text-pattern branch** for proof error special case | +| `UpdateDataContractScreen` | `contracts_documents/update_contract_screen.rs` | `error_message`, `broadcast_status` enum | Pattern C + inline | Custom: same as Register | Custom: `UpdatedContract` | Same pattern as Register | +| `DocumentActionScreen` | `contracts_documents/document_action_screen.rs` | `backend_message: Option` | Not rendered via banner | Custom: sets `backend_message`; **ignores message_type** | Custom: Broadcasted/Deleted | `_message_type` — type parameter unused | +| `GroupActionsScreen` | `contracts_documents/group_actions_screen.rs` | `fetch_group_actions_status` enum | Pattern C (status enum) | Custom: sets status enum | Custom: `ActiveGroupActions` | | + +#### Tokens + +| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | +|---|---|---|---|---|---|---| +| `TokensScreen` | `tokens/tokens_screen/mod.rs` | `backend_message: Option<(String, MessageType, DateTime)>`, `token_creator_error_message` | Pattern B (timed banner) | Custom: smart routing (creator subpanel vs main) | Custom: extensive | **2 fields**; smart dispatch based on state | +| `MintTokensScreen` | `tokens/mint_tokens_screen.rs` | `error_message: Option` | Pattern D (colored_label) | Custom: Error → `error_message` | Default | Color: `DashColors::error_color(dark)` | +| `BurnTokensScreen` | `tokens/burn_tokens_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `DashColors::error_color(dark)` | +| `TransferTokensScreen` | `tokens/transfer_tokens_screen.rs` | `transfer_tokens_status` enum | Pattern C (status enum) | Custom: sets status enum | Custom: `TransferredTokens` | No `error_message` field | +| `FreezeTokensScreen` | `tokens/freeze_tokens_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | **Color: `Color32::RED`** (not DARK_RED) | +| `UnfreezeTokensScreen` | `tokens/unfreeze_tokens_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `Color32::RED` | +| `PauseTokensScreen` | `tokens/pause_tokens_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `Color32::RED` | +| `ResumeTokensScreen` | `tokens/resume_tokens_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `Color32::RED` | +| `DestroyFrozenFundsScreen` | `tokens/destroy_frozen_funds_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `DashColors::error_color(dark)` | +| `ClaimTokensScreen` | `tokens/claim_tokens_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `Color32::RED` | +| `ViewTokenClaimsScreen` | `tokens/view_token_claims_screen.rs` | `message: Option<(String, MessageType, DateTime)>` | Pattern D | Custom: all types → `message` | Custom: calls display_message if empty | | +| `AddTokenByIdScreen` | `tokens/add_token_by_id_screen.rs` | `error_message: Option` | Pattern D | Custom: Success = refresh; Error = `error_message` | (inline) | `display_message` handles Success as task result | +| `PurchaseTokenScreen` | `tokens/direct_token_purchase_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | | +| `SetTokenPriceScreen` | `tokens/set_token_price_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `DARK_RED`; also has amber validation labels | +| `UpdateTokenConfigScreen` | `tokens/update_token_config.rs` | `backend_message: Option<(String, MessageType, DateTime)>`, `error_message` (unused) | Pattern B (timed banner) | Custom: Error/Info → `backend_message` | Custom: `UpdatedTokenConfig` | **`error_message` field is explicitly commented `// unused`** | + +#### Tools + +| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | +|---|---|---|---|---|---|---| +| `PlatformInfoScreen` | `tools/platform_info_screen.rs` | `error_message: Option` | Pattern A (framed banner + Dismiss) | Custom: Error → `error_message` | Custom: PlatformInfo results | **Error blocks result display** (early return) | +| `GroveSTARKScreen` | `tools/grovestark_screen.rs` | `gen_error_message`, `verify_error_message` | Pattern D (with Dismiss in framed box) | Custom: routes to gen/verify based on mode | Custom: GeneratedProof, VerifiedProof | **2 mode-specific error fields** | +| `MasternodeListDiffScreen` | `tools/masternode_list_diff_screen.rs` | `ui_state.message: Option<(String, MessageType)>`, `ui_state.error` | Pattern A (framed banner) | Custom: Error → `ui_state.error`; Info → **silently discarded** | Custom: CoreItem, extensive | **Info messages explicitly dropped** | +| `ProofLogScreen` | `tools/proof_log_screen.rs` | None | None | Explicit no-op | Default | **Fully ignores all messages** | +| `ProofVisualizerScreen` | `tools/proof_visualizer_screen.rs` | None | None | Explicit no-op | Default | **Fully ignores all messages** | +| `TransitionVisualizerScreen` | `tools/transition_visualizer_screen.rs` | `broadcast_status` enum, `contract_fetch_message: Option<(String, Instant)>` | Pattern C (status enum) | Custom: routes by type | Custom: FetchedContract | `contract_fetch_message` is time-based | +| `AddressBalanceScreen` | `tools/address_balance_screen.rs` | `error_message: Option` | Pattern A (framed banner + Dismiss) | Custom: Error → `error_message` | Custom: AddressBalance | Color: `from_rgb(255,100,100)` | + +#### DashPay + +| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | +|---|---|---|---|---|---|---| +| `DashPayScreen` | `dashpay/dashpay_screen.rs` | None (delegates) | Delegates to active sub-screen | Custom: dispatches to sub-screens | Default | Pure delegator | +| `ContactsList` | `dashpay/contacts_list.rs` | `message: Option<(String, MessageType)>` | Pattern D | Custom: sets `message` | Custom | Color: `DARK_RED`/`DARK_GREEN`/`LIGHT_BLUE` | +| `ContactRequests` | `dashpay/contact_requests.rs` | `message: Option<(String, MessageType)>` | Pattern D (bold for errors) | Custom: sets `message` | Custom | | +| `ContactDetailsScreen` | `dashpay/contact_details.rs` | `message: Option<(String, MessageType)>` | Pattern D | **Public + trait impl** (trait delegates to pub fn) | Custom | | +| `ContactInfoEditorScreen` | `dashpay/contact_info_editor.rs` | `message: Option<(String, MessageType)>` | Pattern D | **Public + trait impl** | Custom | Uses `DashColors::SUCCESS/ERROR/INFO` constants | +| `ProfileSearchScreen` | `dashpay/profile_search.rs` | `message: Option<(String, MessageType)>` | Pattern D | **Public + trait impl** | Custom | Color: `DashColors::error_color(dark)` | +| `ContactProfileViewerScreen` | `dashpay/contact_profile_viewer.rs` | `message: Option<(String, MessageType)>` | Pattern D | **Public + trait impl** | Custom | | +| `AddContactScreen` | `dashpay/add_contact_screen.rs` | `message: Option<(String, MessageType)>` | Pattern D | Custom: wraps `AddContactError` | Custom | | +| `QRCodeGeneratorScreen` | `dashpay/qr_code_generator.rs` | `message: Option<(String, MessageType)>` | Pattern D | **Public + trait impl** | Default | | +| `QRScannerScreen` | `dashpay/qr_scanner.rs` | `message: Option<(String, MessageType)>` | Pattern D | **Public + trait impl** | Custom | | +| `SendPaymentScreen` | `dashpay/send_payment.rs` | `message: Option<(String, MessageType)>` | Pattern D | Custom: sets `sending=false` + `message` | Custom: `DashPayPaymentSent` | | + +#### Network / Other + +| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | +|---|---|---|---|---|---|---| +| `NetworkChooserScreen` | `network_chooser_screen.rs` | `custom_dash_qt_error_message`, `spv_clear_message`, `db_clear_message` | Inline specialized fields | Default no-op | Default | **3 specialized local message fields**; no standard message pipeline | + +### Key Issues Summary + +1. **No unified rendering component** — 4 distinct visual patterns with no shared helper +2. **8+ different error colors** — ranges from `Color32::RED` to `Color32::DARK_RED` to `from_rgb(255,100,100)` to `DashColors::error_color(dark)` +3. **Screens silently ignoring messages** — `KeysScreen`, `ProofLogScreen`, `ProofVisualizerScreen` (no-op), `MasternodeListDiffScreen` (drops Info), `DocumentActionScreen` (ignores type) +4. **Redundant dual-field storage** — `TransferScreen` stores errors in both status enum and `error_message` +5. **Dead code** — `UpdateTokenConfigScreen.error_message` explicitly marked unused; `theme.rs::MessageType` is dead code +6. **6 screens with public+trait dual display_message** — DashPay sub-screens have both a `pub fn` and `ScreenLike` impl +7. **Inconsistent auto-dismiss** — 8 screens use `DateTime` timestamps for timed messages; the rest persist until user action +8. **Success messages often silently lost** — Token action screens (Mint, Burn, Freeze, etc.) use default `display_task_result` which calls `display_message("Success", Success)`, but their `display_message` only handles Error + +--- + +## 1. MessageType Enum Unification + +### Current State + +Two competing `MessageType` enums exist: + +1. **`src/ui/mod.rs:830`** -- Active, 3 variants (Success, Info, Error), used by `ScreenLike` trait +2. **`src/ui/theme.rs:638`** -- Dead code (`#[allow(dead_code)]`), 4 variants (Success, Error, Warning, Info), has `color()` and `background_color()` methods + +### Decision + +**Extend** the active enum in `mod.rs` by adding a `Warning` variant. **Consolidate** the color methods from the dead-code `theme.rs::MessageType` into `DashColors` as dark-mode-aware methods (the dead enum only had light-mode colors). Then **delete** the dead-code enum in `theme.rs` (lines 636-664). + +### Unified Enum (in `src/ui/mod.rs`) + +```rust +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum MessageType { + Success, + Info, + Warning, + Error, +} +``` + +This is the only change to the existing `MessageType`. The variant order follows severity (lowest to highest). Adding `Warning` is backward-compatible -- existing match arms that use `Success`, `Info`, `Error` will get a compiler error only in exhaustive matches, which is desirable to ensure all sites handle the new variant. + +### New DashColors Methods (in `src/ui/theme.rs`) + +Add `info_color(dark_mode)` and `message_background_color(message_type, dark_mode)` to `DashColors`, consolidating all message-type-aware colors in one place: + +```rust +impl DashColors { + /// Info severity color -- complements existing error_color/success_color/warning_color. + pub fn info_color(dark_mode: bool) -> Color32 { + if dark_mode { + Color32::from_rgb(100, 180, 255) // Lighter blue for dark mode + } else { + Self::DEEP_BLUE // Dark blue for light mode + } + } + + /// Returns the tinted background color for a message severity level. + /// Uses low alpha (8% light, 12% dark) for subtle tinting. + pub fn message_background_color(message_type: MessageType, dark_mode: bool) -> Color32 { + let alpha = if dark_mode { 30 } else { 20 }; + match message_type { + MessageType::Error => { + let c = if dark_mode { (255, 100, 100) } else { (235, 87, 87) }; + Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha) + } + MessageType::Warning => { + let c = if dark_mode { (255, 200, 100) } else { (241, 196, 15) }; + Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha) + } + MessageType::Success => { + let c = if dark_mode { (80, 200, 120) } else { (39, 174, 96) }; + Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha) + } + MessageType::Info => { + let c = if dark_mode { (100, 180, 255) } else { (52, 152, 219) }; + Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha) + } + } + } + + /// Returns the foreground (text/border) color for a message severity level. + /// Delegates to existing per-severity color methods. + pub fn message_color(message_type: MessageType, dark_mode: bool) -> Color32 { + match message_type { + MessageType::Error => Self::error_color(dark_mode), + MessageType::Warning => Self::warning_color(dark_mode), + MessageType::Success => Self::success_color(dark_mode), + MessageType::Info => Self::info_color(dark_mode), + } + } +} +``` + +This approach: +- Keeps all color definitions in `DashColors` (single source of truth for the design system) +- Reuses existing `error_color`/`success_color`/`warning_color` methods for foreground colors +- Fills the gap: `info_color(dark_mode)` was missing +- Replaces the dead `theme.rs::MessageType` color methods with dark-mode-aware equivalents + +--- + +## 2. MessageBanner Component + +### Alignment with Component Design Pattern + +`MessageBanner` follows the conventions established in `doc/COMPONENT_DESIGN_PATTERN.md`: + +| Convention | How MessageBanner Applies It | +|---|---| +| **Private fields only** | `state: Option` is private | +| **`new()` constructor** | `MessageBanner::new()` creates an empty banner | +| **Builder methods** | `with_auto_dismiss_duration()` for configurable timeout | +| **`show()` method** | `show(&mut self, ui: &mut Ui)` renders the banner | +| **Self-contained** | Handles rendering, dismiss, auto-dismiss, and color resolution internally | +| **Colors via design system** | Uses `DashColors::message_color()`, `DashColors::message_background_color()`, and `DashColors::text_secondary()` — no hardcoded colors | + +**Why MessageBanner does NOT implement the `Component` trait:** + +The `Component` trait (in `component_trait.rs`) is designed for **input** components that produce domain values — `AmountInput` produces `Amount`, `ConfirmationDialog` produces `ConfirmationStatus`. Each has `DomainType`, `ComponentResponse` with `has_changed()`/`changed_value()`/`update()`, and `current_value()`. + +`MessageBanner` is a **display** component that **consumes** data (receives messages) rather than **producing** domain values. It has no meaningful `DomainType` to bind, no user-produced value to return, and no `update()` target. Forcing the `Component` trait would require artificial type parameters (`DomainType = ()`) that add complexity without value. + +This is consistent with how other display-only widgets in the codebase work — e.g., `StyledButton`, `GradientHeading`, `InfoPopup` all have `show()` without implementing `Component`. + +### File Location + +`src/ui/components/message_banner.rs` + +Register in `src/ui/components/mod.rs`: +```rust +pub mod message_banner; +pub use message_banner::MessageBanner; +``` + +### Struct Definition + +```rust +use crate::ui::MessageType; +use crate::ui::theme::DashColors; +use std::time::{Duration, Instant}; + +const DEFAULT_AUTO_DISMISS_DURATION: Duration = Duration::from_secs(5); + +/// A self-contained banner widget for displaying screen-level messages. +/// +/// Each screen owns one instance. Call `show()` every frame inside the +/// screen's `ui()` method, before the `ScrollArea`. The banner renders +/// nothing when no message is set. +/// +/// Follows the component conventions from `doc/COMPONENT_DESIGN_PATTERN.md`: +/// private fields, `new()` constructor, builder methods, `show()` rendering. +pub struct MessageBanner { + /// Current message state: text, severity, and the instant it was set. + /// `None` means no message is displayed. + state: Option, + /// Duration before Success/Info messages auto-dismiss. + auto_dismiss_duration: Duration, +} + +struct MessageState { + text: String, + message_type: MessageType, + created_at: Instant, +} +``` + +The struct is intentionally minimal. `state` is `Option` so the banner occupies zero layout space when empty. `Instant` is used for auto-dismiss timing because it is monotonic and does not depend on wall clock. + +### Public API + +```rust +impl MessageBanner { + /// Creates an empty banner (no message displayed). + pub fn new() -> Self { + Self { + state: None, + auto_dismiss_duration: DEFAULT_AUTO_DISMISS_DURATION, + } + } + + /// Builder: set custom auto-dismiss duration for Success/Info messages. + pub fn with_auto_dismiss_duration(mut self, duration: Duration) -> Self { + self.auto_dismiss_duration = duration; + self + } + + /// Sets or replaces the current message. Resets the auto-dismiss timer. + /// An empty string is treated as a clear operation. + pub fn set_message(&mut self, text: &str, message_type: MessageType) { + if text.is_empty() { + self.state = None; + return; + } + self.state = Some(MessageState { + text: text.to_string(), + message_type, + created_at: Instant::now(), + }); + } + + /// Clears the current message immediately. + pub fn clear(&mut self) { + self.state = None; + } + + /// Returns whether a message is currently displayed. + pub fn has_message(&self) -> bool { + self.state.is_some() + } + + /// Renders the banner into the given `Ui`. + /// Call this every frame before the ScrollArea. + pub fn show(&mut self, ui: &mut egui::Ui) { + // Implementation below + } +} + +impl Default for MessageBanner { + fn default() -> Self { + Self::new() + } +} +``` + +### Rendering Implementation (`show`) + +All colors are resolved through `DashColors` methods — no hardcoded `Color32` values in the component. + +```rust +pub fn show(&mut self, ui: &mut egui::Ui) { + // 1. Check if there is a message to display + let Some(state) = &self.state else { return }; + + // 2. Auto-dismiss check for Success and Info + let auto_dismiss = matches!(state.message_type, MessageType::Success | MessageType::Info); + if auto_dismiss && state.created_at.elapsed() >= self.auto_dismiss_duration { + self.state = None; + return; + } + + // 3. Resolve colors via DashColors (single source of truth) + let dark_mode = ui.ctx().style().visuals.dark_mode; + let fg_color = DashColors::message_color(state.message_type, dark_mode); + let bg_color = DashColors::message_background_color(state.message_type, dark_mode); + let secondary_color = DashColors::text_secondary(dark_mode); + + // 4. Compute remaining seconds for countdown (only for auto-dismiss types) + let remaining_secs = if auto_dismiss { + let elapsed = state.created_at.elapsed(); + self.auto_dismiss_duration + .checked_sub(elapsed) + .map(|d| d.as_secs() + 1) // +1 so we show "1s" until it actually expires + } else { + None + }; + + // 5. Render using DashColors and theme constants + let icon = icon_for_type(state.message_type); + let text = state.text.clone(); // clone to release borrow on self + let mut dismissed = false; + + egui::Frame::new() + .fill(bg_color) + .inner_margin(egui::Margin::symmetric( + Spacing::SM_I8 + Spacing::XXS as i8, // 10px + Spacing::SM_I8, // 8px + )) + .corner_radius(Shape::RADIUS_SM as f32) // 6px + .stroke(egui::Stroke::new(Shape::BORDER_WIDTH, fg_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + // Icon + ui.label(egui::RichText::new(icon).color(fg_color).strong()); + ui.add_space(Spacing::XS); // 4px + + // Message text (wrapping allowed via Label) + ui.label(egui::RichText::new(&text).color(fg_color)); + + // Flexible space + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Dismiss button (rightmost) + if ui.small_button("x").clicked() { + dismissed = true; + } + + // Countdown (before dismiss button in RTL layout) + if let Some(secs) = remaining_secs { + ui.label( + egui::RichText::new(format!("({}s)", secs)) + .font(Typography::body_small()) + .color(secondary_color), + ); + } + }); + }); + }); + ui.add_space(Spacing::SM as f32); // 8px below banner + + if dismissed { + self.state = None; + } +} +``` + +### Color Resolution — All Through DashColors + +The banner uses **zero hardcoded colors**. All color resolution goes through `DashColors`: + +| Purpose | Method | Source | +|---|---|---| +| Text & border color | `DashColors::message_color(type, dark_mode)` | Delegates to `error_color()` / `success_color()` / `warning_color()` / `info_color()` | +| Background tint | `DashColors::message_background_color(type, dark_mode)` | Semantic backgrounds at 8% (light) / 12% (dark) alpha | +| Countdown text | `DashColors::text_secondary(dark_mode)` | Standard secondary text color | +| Spacing values | `Spacing::XS`, `Spacing::SM`, etc. | Theme constants | +| Corner radius | `Shape::RADIUS_SM` (6px) | Theme constants | +| Border width | `Shape::BORDER_WIDTH` (1px) | Theme constants | + +This ensures that if the design system colors are updated globally, the banner automatically reflects the changes. + +### Icon Selection + +```rust +fn icon_for_type(message_type: MessageType) -> &'static str { + match message_type { + MessageType::Error => "\u{26A0}", // ⚠ warning sign (visually distinct via color) + MessageType::Warning => "\u{26A0}", // ⚠ same glyph, differentiated by color + MessageType::Success => "\u{2713}", // ✓ check mark + MessageType::Info => "\u{2139}", // ℹ info + } +} +``` + +Note: If the egui font does not render these Unicode characters, fall back to ASCII: `"!"`, `"!"`, `"v"`, `"i"`. The implementer should verify glyph availability at development time. + +--- + +## 3. ScreenLike Trait Changes + +### Current Signature (unchanged) + +```rust +pub trait ScreenLike { + fn refresh(&mut self) {} + fn refresh_on_arrival(&mut self) { self.refresh() } + fn ui(&mut self, ctx: &Context) -> AppAction; + fn display_message(&mut self, _message: &str, _message_type: MessageType) {} + fn display_task_result(&mut self, _backend_task_success_result: BackendTaskSuccessResult) { + self.display_message("Success", MessageType::Success) + } + fn pop_on_success(&mut self) {} +} +``` + +**No signature change is needed.** The `display_message` method signature already accepts `&str` and `MessageType`. Adding `Warning` to the `MessageType` enum is the only change, and it flows through the existing signature. + +### Default Implementation Consideration + +It would be tempting to add a default `display_message` implementation that delegates to a `banner` field. However, `ScreenLike` is a trait and has no access to struct fields, so no default implementation is possible without an additional accessor method. + +**Option considered and rejected:** Adding a `fn banner(&mut self) -> &mut MessageBanner` method to ScreenLike. This would save boilerplate but forces every screen to have a field named in a specific way, and changes the trait contract for all 45+ implementors simultaneously. The cost of the two-line `display_message` implementation per screen is low. + +**Recommendation:** Each screen implements `display_message` as a two-line method: + +```rust +fn display_message(&mut self, message: &str, message_type: MessageType) { + self.banner.set_message(message, message_type); +} +``` + +Some screens perform additional logic in `display_message` (e.g., `TopUpIdentityScreen` resets its step state on error). Those screens keep their custom logic and add the `banner.set_message()` call. + +--- + +## 4. Migration Plan + +### Phase 1: Create Component + Unify MessageType + Extend DashColors + +**Scope:** 3 files changed, 1 file created + +1. **Update `src/ui/mod.rs`** -- Add `Warning` variant to `MessageType` enum (line 830). +2. **Update `src/ui/theme.rs`** -- Add `info_color(dark_mode)`, `message_color(message_type, dark_mode)`, and `message_background_color(message_type, dark_mode)` to `DashColors`. Delete the dead-code `MessageType` enum and its `impl` block (lines 636-664). +3. **Create `src/ui/components/message_banner.rs`** -- Full `MessageBanner` implementation as described in Section 2, using only `DashColors` for colors and theme constants for spacing/shape. +4. **Update `src/ui/components/mod.rs`** -- Add `pub mod message_banner; pub use message_banner::MessageBanner;` +5. **Fix compile errors** -- Any exhaustive `match` on `MessageType` will need a `Warning` arm. These are likely limited to `app.rs` (the `TaskResult` routing) and any screen that matches on message type in `display_message`. Add `MessageType::Warning => { /* same as Error for now */ }` to each. + +**Validation:** `cargo build` succeeds. No behavioral change yet. + +### Phase 2: Migrate Screens Incrementally + +Each screen migration follows the same mechanical pattern. Screens can be migrated one at a time in separate commits, enabling incremental review. + +**Per-screen changes:** + +1. Add `use crate::ui::components::MessageBanner;` (if not already via glob import) +2. Replace `error_message: Option` (and similar fields) with `banner: MessageBanner` +3. In `new()` / constructor: replace `error_message: None` with `banner: MessageBanner::new()` +4. In `ui()`: replace the inline `Frame` error rendering block with `self.banner.show(ui);` +5. In `display_message()`: replace `self.error_message = Some(...)` with `self.banner.set_message(message, message_type);` +6. Remove any manual dismiss logic (the banner handles it) +7. If the screen sets `self.error_message = Some(...)` inline during `ui()`, replace with `self.banner.set_message(...)` +8. If the screen clears the error (e.g., `self.error_message = None`), replace with `self.banner.clear()` + +**Estimated effort per screen:** ~5-15 lines changed per screen file. Mechanical, low risk. + +**Migration order suggestion:** +1. Start with a simple screen (e.g., `transfer_screen.rs`) as a proof-of-concept +2. Then migrate screens with the exact duplicate pattern (the `Frame` + dismiss button block identical to `top_up_identity_screen`) +3. Then migrate screens with custom `display_message` logic +4. Finally migrate screens that use status enums with `ErrorMessage(String)` variants + +### Phase 3: Remove Dead Code + +1. Delete the `#[allow(dead_code)]` annotations from `theme.rs` if the dead `MessageType` was removed in Phase 1 +2. Remove any helper functions that were only used for the old error rendering pattern (if any exist) +3. Run `cargo clippy` to find any remaining unused code related to the old pattern + +--- + +## 5. What Stays Unchanged + +| Concern | Status | Rationale | +|---------|--------|-----------| +| **Status enums** (Pattern 2, e.g., `TransferCreditsStatus::ErrorMessage(String)`) | Keep as-is | These track screen workflow state. The screen can use both a status enum for flow control and `MessageBanner` for display. When entering the `ErrorMessage` state, the screen calls `self.banner.set_message(...)`. | +| **`show_success_screen()` helper** (Pattern 6, `src/ui/helpers.rs`) | Keep as-is | This renders a full-page success view with detailed info, not a banner. Different purpose entirely. | +| **Inline validation** (Pattern 8, e.g., `AmountInput` error messages) | Keep as-is | These are field-level validation hints rendered next to the input. They are not screen-level messages. | +| **`ConfirmationDialog`** | Keep as-is | Modal dialog for confirming actions. Orthogonal to message banners. | +| **BackendTask / TaskResult / AppState** | Keep as-is | The task routing in `app.rs` calls `display_message()` on the visible screen. This works identically with `MessageBanner` -- the screen's `display_message` impl just delegates to `banner.set_message()`. | +| **`AppAction` enum** | Keep as-is | No new action variants needed. | +| **`BackendTaskSuccessResult`** | Keep as-is | No changes to result types. | +| **`component_trait.rs` (`Component` / `ComponentResponse`)** | Not implemented by `MessageBanner` | `Component` trait is for input widgets that produce domain values (`AmountInput` → `Amount`). `MessageBanner` is a display widget that consumes data. It follows the pattern's conventions (private fields, builder, `show()`, DashColors) without the trait. Same approach as `StyledButton`, `GradientHeading`, `InfoPopup`. | + +--- + +## 6. Code Examples: Before/After Migration + +### Example: `TopUpIdentityScreen` + +#### Before (current code) + +**Struct definition** (`src/ui/identities/top_up_identity_screen/mod.rs:44-66`): +```rust +pub struct TopUpIdentityScreen { + pub identity: QualifiedIdentity, + // ... other fields ... + error_message: Option, + // ... +} +``` + +**Constructor:** +```rust +impl TopUpIdentityScreen { + pub fn new(qualified_identity: QualifiedIdentity, app_context: &Arc) -> Self { + Self { + // ... + error_message: None, + // ... + } + } +} +``` + +**Error rendering in `ui()`** (lines 531-552): +```rust +// Display error message at the top, outside of scroll area +if let Some(error_message) = self.error_message.clone() { + let message_color = egui::Color32::from_rgb(255, 100, 100); + + ui.horizontal(|ui| { + egui::Frame::new() + .fill(message_color.gamma_multiply(0.1)) + .inner_margin(egui::Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, message_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(&error_message).color(message_color)); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.error_message = None; + } + }); + }); + }); + ui.add_space(10.0); +} +``` + +**`display_message` impl** (lines 430-435): +```rust +fn display_message(&mut self, message: &str, message_type: MessageType) { + if message_type == MessageType::Error { + self.error_message = Some(format!("Error topping up identity: {}", message)); + // Reset step so UI is not stuck on waiting messages + let mut step = self.step.write().unwrap(); + if *step == WalletFundedScreenStep::WaitingForPlatformAcceptance + || *step == WalletFundedScreenStep::WaitingForAssetLock + { + *step = WalletFundedScreenStep::ChooseFundingMethod; + } + } +} +``` + +#### After (migrated) + +**Struct definition:** +```rust +use crate::ui::components::MessageBanner; + +pub struct TopUpIdentityScreen { + pub identity: QualifiedIdentity, + // ... other fields ... + banner: MessageBanner, // replaces error_message: Option + // ... +} +``` + +**Constructor:** +```rust +Self { + // ... + banner: MessageBanner::new(), // replaces error_message: None + // ... +} +``` + +**Rendering in `ui()`** (replaces the 20-line block): +```rust +// Display message banner at the top, outside of scroll area +self.banner.show(ui); +``` + +**`display_message` impl** (keeps custom step-reset logic): +```rust +fn display_message(&mut self, message: &str, message_type: MessageType) { + self.banner.set_message(message, message_type); + if message_type == MessageType::Error { + // Reset step so UI is not stuck on waiting messages + let mut step = self.step.write().unwrap(); + if *step == WalletFundedScreenStep::WaitingForPlatformAcceptance + || *step == WalletFundedScreenStep::WaitingForAssetLock + { + *step = WalletFundedScreenStep::ChooseFundingMethod; + } + } +} +``` + +Note that the error message prefix `"Error topping up identity: "` is dropped. The banner's visual styling (red color, error icon) already communicates that it is an error. The backend task error string itself is descriptive enough. + +### Example: Simple Screen (no custom `display_message`) + +Many screens have no custom `display_message` override and rely on the default no-op. After migration, these screens get a one-line `display_message`: + +```rust +fn display_message(&mut self, message: &str, message_type: MessageType) { + self.banner.set_message(message, message_type); +} +``` + +And a one-line render call in `ui()`: + +```rust +self.banner.show(ui); +``` + +--- + +## 7. Architectural Decisions Summary + +| Decision | Rationale | +|----------|-----------| +| Per-screen ownership (not global) | Matches egui immediate-mode model. No shared mutable state. Each screen is self-contained. | +| `Instant` for timing (not egui frame time) | Monotonic, works correctly even if frames are missed while screen is not visible. | +| Follows Component Design Pattern conventions, not `Component` trait | `MessageBanner` is a display widget (consumes data), not an input widget (produces domain values). It follows the pattern's conventions (private fields, builder, `show()`, DashColors) like `StyledButton`/`InfoPopup`. | +| All colors via `DashColors` methods | Zero hardcoded `Color32` values. New `DashColors::message_color()`, `message_background_color()`, and `info_color()` methods ensure single source of truth. If the design system is updated, the banner reflects changes automatically. | +| Delete dead `theme.rs::MessageType`, consolidate into `DashColors` | The dead enum had light-mode-only `color()`/`background_color()` methods. The replacement `DashColors::message_color(type, dark_mode)` and `DashColors::message_background_color(type, dark_mode)` are dark-mode-aware and reusable by any component. | +| No accessor method on `ScreenLike` | Avoids coupling the trait to a specific field name. The two-line `display_message` boilerplate is acceptable. | +| Incremental migration (one screen at a time) | Reduces risk. Each migration is a small, reviewable change. Old and new patterns coexist during migration. | + +--- + +## 8. Alternatives Considered + +### Alternative A: Global App-Level Banner (Rejected) + +Render the banner in `AppState::update()` above all screens, eliminating per-screen ownership. + +**Pros:** Zero migration effort for backend task errors — `AppState` would call `banner.set_message()` directly instead of routing through `display_message()`. + +**Rejected because:** Screens render inside panels with different layouts (left sidebar, main content, modals). A global banner can't know the correct placement within each screen's layout. Also, screens that set validation errors during `ui()` would still need their own banner. Result: two systems instead of one. + +### Alternative B: Implement `Component` Trait (Rejected) + +Make `MessageBanner` implement `Component`. + +**Pros:** Perfectly uniform with `AmountInput` and `ConfirmationDialog`. + +**Rejected because:** `Component` requires `DomainType` (the value the component produces), `ComponentResponse` with `has_changed()`/`changed_value()`/`update()`, and `current_value()`. A display widget has no meaningful domain value to produce. Using `DomainType = ()` and `changed_value() -> &Option<()>` would be semantically empty. The pattern doc itself describes components that "handle wallet selection" or "manage passwords" — input components. Display-only widgets (`StyledButton`, `GradientHeading`, `InfoPopup`) don't use the trait either. + +### Alternative C: Add `fn banner() -> &mut MessageBanner` to ScreenLike (Rejected) + +Add an accessor method to the trait so `AppState` could call `screen.banner().set_message()` directly. + +**Pros:** Eliminates the two-line `display_message` boilerplate per screen. + +**Rejected because:** Forces every `ScreenLike` implementor to have a `MessageBanner` field (including screens that legitimately don't need messages, like `ProofLogScreen`). Changing the trait contract for 50+ screens simultaneously increases migration risk. + +### Alternative D: Use `DateTime` for Timing (Rejected) + +8 screens already use `DateTime` for timed messages. + +**Rejected because:** `Instant` is monotonic (immune to system clock changes), simpler (no chrono dependency needed), and purpose-built for duration measurement. `DateTime` is correct for display timestamps but wrong for timeout logic. diff --git a/docs/ai-design/2026-02-17-unified-messages/message-banner-mockup.html b/docs/ai-design/2026-02-17-unified-messages/message-banner-mockup.html new file mode 100644 index 000000000..baccfa7c0 --- /dev/null +++ b/docs/ai-design/2026-02-17-unified-messages/message-banner-mockup.html @@ -0,0 +1,445 @@ + + + + + +MessageBanner Component Mockup - Dash Evo Tool + + + + +
+ +
+ Dash Evo Tool +
+ +
+
+ +
+ + + + +
+ + + + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ Mockup reference for MessageBanner component. Spec: message-banner-ux-spec.md | Corner radius: 6px | Padding: 10px h, 8px v | Border: 1px | Gap between icon and text: 4px +
+
+
+
+ + + + + diff --git a/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md b/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md new file mode 100644 index 000000000..45123dd03 --- /dev/null +++ b/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md @@ -0,0 +1,271 @@ +# MessageBanner Component -- UX Specification + +## 1. Overview + +The `MessageBanner` is a self-contained egui component that replaces all ad-hoc `error_message: Option` fields across screens. Each screen owns one `MessageBanner` instance (not global). It displays a single message at a time with consistent styling based on severity. + +--- + +## 2. Severity Levels + +| Severity | Persistence | Dismiss | Use Case | +|----------|---------------------|--------------------|-----------------------------------------------| +| Error | Persistent | Manual only | Task failures, validation errors | +| Warning | Persistent | Manual only | Risky actions, degraded state | +| Success | Auto-dismiss ~5s | Manual or auto | Completed operations | +| Info | Auto-dismiss ~5s | Manual or auto | Informational feedback, neutral status updates | + +All four types display a dismiss button. Success and Info also count down and auto-clear. + +--- + +## 3. Visual Design + +### 3.1 Layout Structure + +``` ++-----------------------------------------------------------------------+ +| [Icon] Message text here [5s] [Dismiss] | ++-----------------------------------------------------------------------+ +``` + +The banner is a single horizontal row inside an `egui::Frame` with: +- **Corner radius**: 6px (`Shape::RADIUS_SM`) +- **Inner margin**: 10px horizontal, 8px vertical (matching existing pattern in `top_up_identity_screen`) +- **Outer spacing**: 10px below the banner (`ui.add_space(10.0)`) +- **Full available width**: The frame should expand to `ui.available_width()` + +### 3.2 Content Arrangement (left to right) + +1. **Icon character** (Unicode text, not image): Provides at-a-glance severity recognition. + - Error: `!` (exclamation in circle, or literal `!` if unicode unavailable) + - Warning: `!` (triangle-style -- visually distinct from error via color) + - Success: Checkmark character + - Info: `i` (info style) + - Font size: `Typography::SCALE_BASE` (16px), bold + - Color: Same as the text color for that severity + +2. **4px gap** (`Spacing::XS`) + +3. **Message text**: Left-aligned, wrapping permitted for long messages. + - Font: `Typography::body()` (16px proportional) + - Color: Severity-specific foreground color (see Section 3.3) + +4. **Flexible space** (push remaining elements to the right) + +5. **Countdown label** (Success and Info only): Shows remaining seconds, e.g., `(3s)`. + - Font: `Typography::body_small()` (14px) + - Color: Same as message text, at reduced opacity or using `text_secondary` + +6. **Dismiss button**: Small text button labeled with an `x` character. + - Uses `ui.small_button("x")` + - Clicking sets the banner state to None + +### 3.3 Color Palette + +All colors are resolved through `DashColors` methods — the banner contains zero hardcoded color values. The banner uses a **tinted background + colored border + colored text** approach. + +#### Color Resolution via DashColors + +| Purpose | DashColors Method | Description | +|---|---|---| +| Text & border | `DashColors::message_color(type, dark_mode)` | Delegates to `error_color()` / `success_color()` / `warning_color()` / `info_color()` | +| Background tint | `DashColors::message_background_color(type, dark_mode)` | Severity color at 8% alpha (light) or 12% alpha (dark) | +| Countdown text | `DashColors::text_secondary(dark_mode)` | Standard secondary text color | + +#### Resolved Values (for visual reference) + +**Light Mode:** + +| Severity | Background | Text & Border | +|----------|-----------|---------------| +| Error | `ERROR` at 8% alpha | `DashColors::error_color(false)` → `DARK_RED` | +| Warning | `WARNING` at 8% alpha | `DashColors::warning_color(false)` → dark amber | +| Success | `SUCCESS` at 8% alpha | `DashColors::success_color(false)` → `DARK_GREEN` | +| Info | `INFO` at 8% alpha | `DashColors::info_color(false)` → `DEEP_BLUE` | + +**Dark Mode:** + +| Severity | Background | Text & Border | +|----------|-----------|---------------| +| Error | lighter red at 12% alpha | `DashColors::error_color(true)` → `rgb(255, 100, 100)` | +| Warning | lighter amber at 12% alpha | `DashColors::warning_color(true)` → `rgb(255, 200, 100)` | +| Success | muted green at 12% alpha | `DashColors::success_color(true)` → `rgb(80, 160, 80)` | +| Info | light blue at 12% alpha | `DashColors::info_color(true)` → `rgb(100, 180, 255)` | + +Dark mode uses higher alpha backgrounds (12% vs 8%) to maintain visibility against dark surfaces. + +#### Border Stroke +- Width: `Shape::BORDER_WIDTH` (1px) +- Color: `DashColors::message_color(type, dark_mode)` — same as text color + +### 3.4 Typography + +- Icon: `Typography::SCALE_BASE` (16px), bold (`RichText::strong()`) +- Message body: `Typography::body()` (16px), normal weight +- Countdown: `Typography::body_small()` (14px), normal weight, secondary text color +- Dismiss button: egui default `small_button` styling + +--- + +## 4. Placement + +The banner renders at the **top of the screen's content area**, before the `ScrollArea`. This matches the existing convention in `top_up_identity_screen/mod.rs:531-552` and `add_new_identity_screen/mod.rs:1071-1092`. + +``` ++--------------------------------------------------+ +| Top Panel (header / navigation) | ++--------------------------------------------------+ +| Left Panel | [ MessageBanner ] | <-- here +| | +----- ScrollArea -----+ | +| | | Screen content | | +| | | ... | | +| | +----------------------+ | ++--------------------------------------------------+ +``` + +The banner must be rendered **outside** the `ScrollArea` so it remains visible regardless of scroll position. This is consistent with current best practice in the codebase. + +--- + +## 5. Behavior + +### 5.1 Showing a Message + +Calling `banner.set_message("text", MessageType::Error)` replaces any currently displayed message. There is no queue. The new message immediately takes effect. + +For auto-dismissing types (Success, Info), the component records the timestamp when the message was set (using `Instant::now()` or egui frame time). + +### 5.2 Auto-Dismiss (Success, Info) + +- Duration: 5 seconds +- The countdown label shows remaining whole seconds: `(5s)`, `(4s)`, ..., `(1s)` +- When the timer expires, the message clears automatically on the next frame +- The screen must call `banner.show(ui)` each frame (standard egui immediate mode) +- The component internally checks elapsed time and clears itself + +### 5.3 Manual Dismiss + +- All severity types display a dismiss button +- Clicking the dismiss button immediately clears the message +- No confirmation needed + +### 5.4 Message Replacement + +- Setting a new message while one is showing replaces it immediately +- If the old message was Error (persistent) and the new one is Success (auto-dismiss), the Success behavior applies +- Timer resets on replacement + +### 5.5 Screen Navigation + +- When the user navigates away from a screen and returns, persistent messages (Error, Warning) should still be visible if the screen struct was retained (root screens in `main_screens` BTreeMap) +- Auto-dismiss messages that expired while the screen was not visible should be gone on return +- Modal/detail screens pushed onto `screen_stack` are destroyed when popped, so their messages naturally disappear + +--- + +## 6. Component API (Behavioral Spec) + +This describes the public interface the component should expose. Not a full Rust implementation, but a behavioral contract for the architect and implementer. + +``` +MessageBanner + State: + - message: Option<(String, MessageType, Instant)> + + Methods: + - new() -> Self // empty, no message + - set_message(text: &str, msg_type: MessageType) // set/replace message + - clear() // manually clear + - show(ui: &mut Ui) // render; auto-dismiss check happens here + + MessageType (unified, replaces both existing enums): + - Error + - Warning + - Success + - Info +``` + +The component does NOT implement the `Component` trait from `component_trait.rs` because it has no domain data to bind via `update()`. It is a simpler display-only widget. Screens call `show(ui)` and `set_message(...)` directly. + +--- + +## 7. Integration Points + +### 7.1 ScreenLike Trait + +The existing `display_message(&mut self, message: &str, message_type: MessageType)` method on `ScreenLike` is the integration point. Screens that adopt `MessageBanner` implement it as: + +``` +fn display_message(&mut self, message: &str, message_type: MessageType) { + self.banner.set_message(message, message_type); +} +``` + +### 7.2 Replacing Existing Fields + +Each screen replaces its ad-hoc fields: +- `error_message: Option` -> removed +- `info_message: Option` -> removed +- `message: Option<(String, MessageType)>` -> removed +- `backend_message: Option<(String, MessageType, DateTime)>` -> removed + +All replaced by a single: +- `banner: MessageBanner` + +### 7.3 Sync Error Display (Validation) + +For validation errors set during `ui()`: + +``` +if some_validation_fails { + self.banner.set_message("Invalid input: ...", MessageType::Error); +} +``` + +--- + +## 8. Edge Cases + +| Scenario | Behavior | +|----------|----------| +| Very long message (300+ chars) | Text wraps within the frame. Frame grows vertically. No truncation. | +| Empty string message | Treated as no message; banner not shown. | +| Rapid message replacement | Each call to `set_message` replaces immediately. No debounce. | +| Multiple error fields on one screen | All consolidated into a single banner. If multiple errors need display, concatenate them with newlines before calling `set_message`. | +| Screen with multiple independent sections | Each section could own its own `MessageBanner` if needed, but the default is one per screen. | +| Theme change while message is showing | Colors re-evaluate each frame via `ui.ctx().style().visuals.dark_mode`. No stale colors. | + +--- + +## 9. Accessibility + +- **Color contrast**: All text/background combinations meet WCAG 2.1 AA contrast ratio (4.5:1 minimum). The colored text on tinted backgrounds achieves this because the backgrounds are near-transparent (8-12% alpha) over the page background. +- **Not color-only**: The icon character provides a non-color severity indicator (distinct shapes for error vs warning vs success vs info). +- **Text is selectable**: egui labels allow text selection by default. +- **Keyboard**: The dismiss button is focusable and activatable via keyboard in egui's default tab-order. No special focus management needed. +- **Screen readers**: Not directly applicable (egui does not have native screen reader support), but the text content is programmatically accessible via egui's accessibility layer if enabled. + +--- + +## 10. What This Spec Does NOT Cover + +- Toast/notification stacking (out of scope -- one message per screen) +- Animation or transitions (egui does not support CSS-style transitions; show/hide is instant) +- Sound or haptic feedback +- Message persistence across app restarts +- Global overlay banners +- Changes to BackendTask/TaskResult/AppState architecture + +--- + +## 11. Migration Strategy (UX Perspective) + +The visual result after migration should be: +1. Every screen shows messages in exactly the same visual style +2. Error messages appear in the same position (top of content, before scroll area) +3. Success messages auto-clear after 5 seconds with a visible countdown +4. No more inline `colored_label` errors scattered at arbitrary positions in screen layouts +5. The Warning severity becomes available for the first time (currently missing from `MessageType` in `mod.rs`) + +Screens that currently use `colored_label` for inline validation hints (e.g., "Field is required") are a separate concern and should remain inline. The `MessageBanner` replaces only the screen-level status/result messages. From 7e167131516947961e49bc7f292d5a1941550eb1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:03:29 +0100 Subject: [PATCH 02/19] feat: add MessageType::Warning --- .../add_contracts_screen.rs | 9 +- .../group_actions_screen.rs | 9 +- src/ui/dashpay/add_contact_screen.rs | 1 + src/ui/dashpay/contact_details.rs | 1 + src/ui/dashpay/contact_info_editor.rs | 1 + src/ui/dashpay/contact_profile_viewer.rs | 1 + src/ui/dashpay/contact_requests.rs | 1 + src/ui/dashpay/contacts_list.rs | 1 + src/ui/dashpay/profile_screen.rs | 1 + src/ui/dashpay/profile_search.rs | 1 + src/ui/dashpay/qr_code_generator.rs | 1 + src/ui/dashpay/qr_scanner.rs | 1 + src/ui/dashpay/send_payment.rs | 6 ++ src/ui/dpns/dpns_contested_names_screen.rs | 1 + src/ui/mod.rs | 1 + src/ui/theme.rs | 90 ++++++++++++------- src/ui/tokens/add_token_by_id_screen.rs | 2 +- src/ui/tokens/tokens_screen/mod.rs | 1 + src/ui/tokens/update_token_config.rs | 2 +- src/ui/tokens/view_token_claims_screen.rs | 6 +- src/ui/tools/masternode_list_diff_screen.rs | 3 +- src/ui/tools/transition_visualizer_screen.rs | 2 +- src/ui/wallets/asset_lock_detail_screen.rs | 1 + src/ui/wallets/create_asset_lock_screen.rs | 1 + src/ui/wallets/send_screen.rs | 2 +- src/ui/wallets/single_key_send_screen.rs | 1 + src/ui/wallets/wallets_screen/mod.rs | 1 + 27 files changed, 96 insertions(+), 52 deletions(-) diff --git a/src/ui/contracts_documents/add_contracts_screen.rs b/src/ui/contracts_documents/add_contracts_screen.rs index a0d69c5c4..f2773351b 100644 --- a/src/ui/contracts_documents/add_contracts_screen.rs +++ b/src/ui/contracts_documents/add_contracts_screen.rs @@ -271,15 +271,10 @@ impl AddContractsScreen { impl ScreenLike for AddContractsScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { match message_type { - MessageType::Success => { - // Not used - } - MessageType::Error => { + MessageType::Error | MessageType::Warning => { self.add_contracts_status = AddContractsStatus::ErrorMessage(message.to_string()); } - MessageType::Info => { - // Not used - } + MessageType::Success | MessageType::Info => {} } } diff --git a/src/ui/contracts_documents/group_actions_screen.rs b/src/ui/contracts_documents/group_actions_screen.rs index 278376cea..e2977864d 100644 --- a/src/ui/contracts_documents/group_actions_screen.rs +++ b/src/ui/contracts_documents/group_actions_screen.rs @@ -453,16 +453,11 @@ impl ScreenLike for GroupActionsScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { match message_type { - MessageType::Success => { - // Not used - } - MessageType::Error => { + MessageType::Error | MessageType::Warning => { self.fetch_group_actions_status = FetchGroupActionsStatus::ErrorMessage(message.to_string()); } - MessageType::Info => { - // Not used - } + MessageType::Success | MessageType::Info => {} } } diff --git a/src/ui/dashpay/add_contact_screen.rs b/src/ui/dashpay/add_contact_screen.rs index f59ee79df..eb16e18f1 100644 --- a/src/ui/dashpay/add_contact_screen.rs +++ b/src/ui/dashpay/add_contact_screen.rs @@ -242,6 +242,7 @@ impl ScreenLike for AddContactScreen { let color = match message_type { MessageType::Success => egui::Color32::DARK_GREEN, MessageType::Error => egui::Color32::DARK_RED, + MessageType::Warning => DashColors::WARNING, MessageType::Info => egui::Color32::LIGHT_BLUE, }; ui.colored_label(color, message); diff --git a/src/ui/dashpay/contact_details.rs b/src/ui/dashpay/contact_details.rs index c27d79fd0..5ca9f8e50 100644 --- a/src/ui/dashpay/contact_details.rs +++ b/src/ui/dashpay/contact_details.rs @@ -148,6 +148,7 @@ impl ContactDetailsScreen { let color = match message_type { MessageType::Success => egui::Color32::DARK_GREEN, MessageType::Error => egui::Color32::DARK_RED, + MessageType::Warning => DashColors::WARNING, MessageType::Info => egui::Color32::LIGHT_BLUE, }; ui.colored_label(color, message); diff --git a/src/ui/dashpay/contact_info_editor.rs b/src/ui/dashpay/contact_info_editor.rs index 01bb458c1..d2b0d74d5 100644 --- a/src/ui/dashpay/contact_info_editor.rs +++ b/src/ui/dashpay/contact_info_editor.rs @@ -140,6 +140,7 @@ impl ContactInfoEditorScreen { let color = match message_type { MessageType::Success => DashColors::SUCCESS, MessageType::Error => DashColors::ERROR, + MessageType::Warning => DashColors::WARNING, MessageType::Info => DashColors::INFO, }; ui.colored_label(color, message); diff --git a/src/ui/dashpay/contact_profile_viewer.rs b/src/ui/dashpay/contact_profile_viewer.rs index 77c6e59e6..e6fcbac1b 100644 --- a/src/ui/dashpay/contact_profile_viewer.rs +++ b/src/ui/dashpay/contact_profile_viewer.rs @@ -230,6 +230,7 @@ impl ContactProfileViewerScreen { let color = match message_type { MessageType::Success => DashColors::success_color(dark_mode), MessageType::Error => DashColors::error_color(dark_mode), + MessageType::Warning => DashColors::warning_color(dark_mode), MessageType::Info => DashColors::DASH_BLUE, }; ui.colored_label(color, message); diff --git a/src/ui/dashpay/contact_requests.rs b/src/ui/dashpay/contact_requests.rs index 2bc93b72c..9f6dfecb7 100644 --- a/src/ui/dashpay/contact_requests.rs +++ b/src/ui/dashpay/contact_requests.rs @@ -435,6 +435,7 @@ impl ContactRequests { let color = match message_type { MessageType::Success => egui::Color32::DARK_GREEN, MessageType::Error => egui::Color32::DARK_RED, + MessageType::Warning => DashColors::WARNING, MessageType::Info => egui::Color32::LIGHT_BLUE, }; // Only show error messages here if there's no structured error diff --git a/src/ui/dashpay/contacts_list.rs b/src/ui/dashpay/contacts_list.rs index 0abd74e1d..5192991ab 100644 --- a/src/ui/dashpay/contacts_list.rs +++ b/src/ui/dashpay/contacts_list.rs @@ -535,6 +535,7 @@ impl ContactsList { let color = match message_type { MessageType::Success => egui::Color32::DARK_GREEN, MessageType::Error => egui::Color32::DARK_RED, + MessageType::Warning => DashColors::WARNING, MessageType::Info => egui::Color32::LIGHT_BLUE, }; ui.colored_label(color, message); diff --git a/src/ui/dashpay/profile_screen.rs b/src/ui/dashpay/profile_screen.rs index bb79b89b7..a97aac44b 100644 --- a/src/ui/dashpay/profile_screen.rs +++ b/src/ui/dashpay/profile_screen.rs @@ -661,6 +661,7 @@ impl ProfileScreen { let color = match message_type { MessageType::Success => egui::Color32::DARK_GREEN, MessageType::Error => egui::Color32::DARK_RED, + MessageType::Warning => DashColors::WARNING, MessageType::Info => egui::Color32::LIGHT_BLUE, }; ui.colored_label(color, message); diff --git a/src/ui/dashpay/profile_search.rs b/src/ui/dashpay/profile_search.rs index 4b9779430..a22810f4e 100644 --- a/src/ui/dashpay/profile_search.rs +++ b/src/ui/dashpay/profile_search.rs @@ -122,6 +122,7 @@ impl ProfileSearchScreen { let color = match message_type { MessageType::Success => DashColors::success_color(dark_mode), MessageType::Error => DashColors::error_color(dark_mode), + MessageType::Warning => DashColors::warning_color(dark_mode), MessageType::Info => DashColors::DASH_BLUE, }; ui.colored_label(color, message); diff --git a/src/ui/dashpay/qr_code_generator.rs b/src/ui/dashpay/qr_code_generator.rs index 10600240a..ac76a407e 100644 --- a/src/ui/dashpay/qr_code_generator.rs +++ b/src/ui/dashpay/qr_code_generator.rs @@ -150,6 +150,7 @@ impl QRCodeGeneratorScreen { let color = match message_type { MessageType::Success => DashColors::success_color(dark_mode), MessageType::Error => DashColors::error_color(dark_mode), + MessageType::Warning => DashColors::warning_color(dark_mode), MessageType::Info => DashColors::DASH_BLUE, }; ui.colored_label(color, message); diff --git a/src/ui/dashpay/qr_scanner.rs b/src/ui/dashpay/qr_scanner.rs index 294180b4d..5eafeb4a6 100644 --- a/src/ui/dashpay/qr_scanner.rs +++ b/src/ui/dashpay/qr_scanner.rs @@ -128,6 +128,7 @@ impl QRScannerScreen { let color = match message_type { MessageType::Success => DashColors::success_color(dark_mode), MessageType::Error => DashColors::error_color(dark_mode), + MessageType::Warning => DashColors::warning_color(dark_mode), MessageType::Info => DashColors::DASH_BLUE, }; ui.colored_label(color, message); diff --git a/src/ui/dashpay/send_payment.rs b/src/ui/dashpay/send_payment.rs index ceba08405..f5e9dd89c 100644 --- a/src/ui/dashpay/send_payment.rs +++ b/src/ui/dashpay/send_payment.rs @@ -185,6 +185,9 @@ impl SendPaymentScreen { let color = match message_type { MessageType::Success => egui::Color32::DARK_GREEN, MessageType::Error => egui::Color32::DARK_RED, + MessageType::Warning => { + DashColors::warning_color(ui.ctx().style().visuals.dark_mode) + } MessageType::Info => egui::Color32::LIGHT_BLUE, }; ui.colored_label(color, message); @@ -649,6 +652,9 @@ impl PaymentHistory { let color = match message_type { MessageType::Success => egui::Color32::DARK_GREEN, MessageType::Error => egui::Color32::DARK_RED, + MessageType::Warning => { + DashColors::warning_color(ui.ctx().style().visuals.dark_mode) + } MessageType::Info => egui::Color32::LIGHT_BLUE, }; ui.colored_label(color, message); diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index 3eaa52154..e749959af 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -2114,6 +2114,7 @@ impl ScreenLike for DPNSScreen { let dark_mode = ui.ctx().style().visuals.dark_mode; let color = match msg_type { MessageType::Error => Color32::DARK_RED, + MessageType::Warning => DashColors::warning_color(dark_mode), MessageType::Info => DashColors::text_primary(dark_mode), MessageType::Success => Color32::DARK_GREEN, }; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 152be79fd..84b8ac96b 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -830,6 +830,7 @@ impl Screen { pub enum MessageType { Success, Info, + Warning, Error, } diff --git a/src/ui/theme.rs b/src/ui/theme.rs index b8e7cbc7e..dd186acfd 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -324,6 +324,66 @@ impl DashColors { } } + pub fn info_color(dark_mode: bool) -> Color32 { + if dark_mode { + Color32::from_rgb(100, 180, 255) // Lighter blue for dark mode + } else { + Self::DEEP_BLUE + } + } + + /// Returns the foreground (text/border) color for a message severity level. + pub fn message_color(message_type: crate::ui::MessageType, dark_mode: bool) -> Color32 { + match message_type { + crate::ui::MessageType::Error => Self::error_color(dark_mode), + crate::ui::MessageType::Warning => Self::warning_color(dark_mode), + crate::ui::MessageType::Success => Self::success_color(dark_mode), + crate::ui::MessageType::Info => Self::info_color(dark_mode), + } + } + + /// Returns the tinted background color for a message severity level. + pub fn message_background_color( + message_type: crate::ui::MessageType, + dark_mode: bool, + ) -> Color32 { + let alpha = if dark_mode { 30 } else { 20 }; + match message_type { + crate::ui::MessageType::Error => { + let c = if dark_mode { + (255, 100, 100) + } else { + (235, 87, 87) + }; + Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha) + } + crate::ui::MessageType::Warning => { + let c = if dark_mode { + (255, 200, 100) + } else { + (241, 196, 15) + }; + Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha) + } + crate::ui::MessageType::Success => { + let c = if dark_mode { + (80, 200, 120) + } else { + (39, 174, 96) + }; + Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha) + } + crate::ui::MessageType::Info => { + let c = if dark_mode { + (100, 180, 255) + } else { + (52, 152, 219) + }; + Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha) + } + } + } + pub fn muted_color(dark_mode: bool) -> Color32 { if dark_mode { Color32::from_rgb(150, 150, 150) // Lighter gray for dark mode @@ -809,33 +869,3 @@ pub fn apply_theme(ctx: &egui::Context, theme_mode: ThemeMode) { ctx.set_style(style); ctx.set_fonts(configure_fonts()); } - -/// Message type styling -#[allow(dead_code)] -pub enum MessageType { - Success, - Error, - Warning, - Info, -} - -#[allow(dead_code)] -impl MessageType { - pub fn color(&self) -> Color32 { - match self { - MessageType::Success => DashColors::SUCCESS, - MessageType::Error => DashColors::ERROR, - MessageType::Warning => DashColors::WARNING, - MessageType::Info => DashColors::INFO, - } - } - - pub fn background_color(&self) -> Color32 { - match self { - MessageType::Success => Color32::from_rgba_unmultiplied(39, 174, 96, 20), - MessageType::Error => Color32::from_rgba_unmultiplied(235, 87, 87, 20), - MessageType::Warning => Color32::from_rgba_unmultiplied(241, 196, 15, 20), - MessageType::Info => Color32::from_rgba_unmultiplied(52, 152, 219, 20), - } - } -} diff --git a/src/ui/tokens/add_token_by_id_screen.rs b/src/ui/tokens/add_token_by_id_screen.rs index 45185f97c..95d469c42 100644 --- a/src/ui/tokens/add_token_by_id_screen.rs +++ b/src/ui/tokens/add_token_by_id_screen.rs @@ -270,7 +270,7 @@ impl ScreenLike for AddTokenByIdScreen { self.status = AddTokenStatus::Error(msg.to_owned()); } } - MessageType::Error => { + MessageType::Error | MessageType::Warning => { // Handle any error during the add token process if msg.contains("Error inserting contract into the database") { self.status = AddTokenStatus::Error("Failed to add token to database".into()); diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index b9d097e9c..6f6039a7a 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -2942,6 +2942,7 @@ impl ScreenLike for TokensScreen { let dark_mode = ui.ctx().style().visuals.dark_mode; let color = match msg_type { MessageType::Error => Color32::DARK_RED, + MessageType::Warning => DashColors::warning_color(dark_mode), MessageType::Info => DashColors::text_primary(dark_mode), MessageType::Success => Color32::DARK_GREEN, }; diff --git a/src/ui/tokens/update_token_config.rs b/src/ui/tokens/update_token_config.rs index 82ea57b3f..c839d5eaa 100644 --- a/src/ui/tokens/update_token_config.rs +++ b/src/ui/tokens/update_token_config.rs @@ -1101,7 +1101,7 @@ impl ScreenLike for UpdateTokenConfigScreen { MessageType::Success => { ui.colored_label(Color32::DARK_GREEN, &msg); } - MessageType::Error => { + MessageType::Error | MessageType::Warning => { let dark_mode = ui.ctx().style().visuals.dark_mode; let error_color = DashColors::error_color(dark_mode); Frame::new() diff --git a/src/ui/tokens/view_token_claims_screen.rs b/src/ui/tokens/view_token_claims_screen.rs index cc9635c68..adc713c16 100644 --- a/src/ui/tokens/view_token_claims_screen.rs +++ b/src/ui/tokens/view_token_claims_screen.rs @@ -74,8 +74,8 @@ impl ScreenLike for ViewTokenClaimsScreen { MessageType::Success => { self.message = Some((message.to_string(), MessageType::Success, Utc::now())); } - MessageType::Error => { - self.message = Some((message.to_string(), MessageType::Error, Utc::now())); + MessageType::Error | MessageType::Warning => { + self.message = Some((message.to_string(), message_type, Utc::now())); if message.contains("Error fetching documents") { self.fetch_status = FetchStatus::NotFetching; } @@ -153,7 +153,7 @@ impl ScreenLike for ViewTokenClaimsScreen { MessageType::Success => { ui.colored_label(DashColors::success_color(dark_mode), msg); } - MessageType::Error => { + MessageType::Error | MessageType::Warning => { ui.colored_label(DashColors::error_color(dark_mode), msg); } MessageType::Info => { diff --git a/src/ui/tools/masternode_list_diff_screen.rs b/src/ui/tools/masternode_list_diff_screen.rs index 8697d4552..41978be75 100644 --- a/src/ui/tools/masternode_list_diff_screen.rs +++ b/src/ui/tools/masternode_list_diff_screen.rs @@ -1565,6 +1565,7 @@ impl MasternodeListDiffScreen { let dark_mode = ui.ctx().style().visuals.dark_mode; let message_color = match msg_type { MessageType::Error => DashColors::ERROR, + MessageType::Warning => DashColors::WARNING, MessageType::Info => DashColors::text_primary(dark_mode), // Dark green for success text MessageType::Success => Color32::DARK_GREEN, @@ -4181,7 +4182,7 @@ impl MasternodeListDiffScreen { impl ScreenLike for MasternodeListDiffScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { match message_type { - MessageType::Error => { + MessageType::Error | MessageType::Warning => { self.task.pending = None; self.ui_state.error = Some(message.to_string()); } diff --git a/src/ui/tools/transition_visualizer_screen.rs b/src/ui/tools/transition_visualizer_screen.rs index 8faa82106..7cc157ee2 100644 --- a/src/ui/tools/transition_visualizer_screen.rs +++ b/src/ui/tools/transition_visualizer_screen.rs @@ -414,7 +414,7 @@ impl ScreenLike for TransitionVisualizerScreen { self.broadcast_status = TransitionBroadcastStatus::Complete(Instant::now()); } } - MessageType::Error => { + MessageType::Error | MessageType::Warning => { self.broadcast_status = TransitionBroadcastStatus::Error(message.to_string(), Instant::now()); } diff --git a/src/ui/wallets/asset_lock_detail_screen.rs b/src/ui/wallets/asset_lock_detail_screen.rs index 28bcf528b..53abb04a3 100644 --- a/src/ui/wallets/asset_lock_detail_screen.rs +++ b/src/ui/wallets/asset_lock_detail_screen.rs @@ -364,6 +364,7 @@ impl ScreenLike for AssetLockDetailScreen { if let Some((message, message_type, timestamp)) = &self.message { let message_color = match message_type { MessageType::Error => egui::Color32::DARK_RED, + MessageType::Warning => DashColors::warning_color(dark_mode), MessageType::Info => DashColors::text_primary(dark_mode), MessageType::Success => egui::Color32::DARK_GREEN, }; diff --git a/src/ui/wallets/create_asset_lock_screen.rs b/src/ui/wallets/create_asset_lock_screen.rs index c4456404a..b0d28d310 100644 --- a/src/ui/wallets/create_asset_lock_screen.rs +++ b/src/ui/wallets/create_asset_lock_screen.rs @@ -694,6 +694,7 @@ impl ScreenLike for CreateAssetLockScreen { if let Some((message, message_type, timestamp)) = &self.message { let message_color = match message_type { MessageType::Error => egui::Color32::DARK_RED, + MessageType::Warning => DashColors::warning_color(dark_mode), MessageType::Info => DashColors::text_primary(dark_mode), MessageType::Success => egui::Color32::DARK_GREEN, }; diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index bfd70e3dc..4e0c9f859 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -2672,7 +2672,7 @@ impl ScreenLike for WalletSendScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { match message_type { - MessageType::Error => { + MessageType::Error | MessageType::Warning => { self.send_status = SendStatus::Error(message.to_string()); } MessageType::Success => { diff --git a/src/ui/wallets/single_key_send_screen.rs b/src/ui/wallets/single_key_send_screen.rs index e7c759c45..9528d9020 100644 --- a/src/ui/wallets/single_key_send_screen.rs +++ b/src/ui/wallets/single_key_send_screen.rs @@ -886,6 +886,7 @@ impl ScreenLike for SingleKeyWalletSendScreen { let message = message.clone(); let message_color = match message_type { MessageType::Error => DashColors::ERROR, + MessageType::Warning => DashColors::WARNING, MessageType::Info => DashColors::text_primary(dark_mode), MessageType::Success => Color32::DARK_GREEN, }; diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index fa9550ebb..712cd42d4 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1372,6 +1372,7 @@ impl ScreenLike for WalletsBalancesScreen { if let Some((message, message_type, _timestamp)) = message { let message_color = match message_type { MessageType::Error => DashColors::ERROR, + MessageType::Warning => DashColors::WARNING, MessageType::Info => DashColors::text_primary(dark_mode), MessageType::Success => egui::Color32::DARK_GREEN, }; From 75f09b5f2d790493a0ab8e1b8a995384394e2cf6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:50:41 +0100 Subject: [PATCH 03/19] chore: initial implementation --- CLAUDE.md | 4 + doc/COMPONENT_DESIGN_PATTERN.md | 22 + .../architecture.md | 88 ++++ src/app.rs | 17 + src/context/mod.rs | 9 + src/ui/components/message_banner.rs | 480 ++++++++++++++++++ src/ui/components/mod.rs | 2 + src/ui/components/styled.rs | 5 +- src/ui/identities/identities_screen.rs | 111 +--- .../by_platform_address.rs | 4 +- .../by_using_unused_asset_lock.rs | 22 +- .../by_using_unused_balance.rs | 36 +- .../by_wallet_qr_code.rs | 37 +- .../identities/top_up_identity_screen/mod.rs | 37 +- src/ui/tokens/mint_tokens_screen.rs | 66 +-- src/ui/tokens/tokens_screen/mod.rs | 3 + src/ui/wallets/wallets_screen/dialogs.rs | 1 + tests/kittest/main.rs | 1 + tests/kittest/message_banner.rs | 443 ++++++++++++++++ 19 files changed, 1188 insertions(+), 200 deletions(-) create mode 100644 src/ui/components/message_banner.rs create mode 100644 tests/kittest/message_banner.rs diff --git a/CLAUDE.md b/CLAUDE.md index 8f0ccd02c..45b392c84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -152,6 +152,10 @@ response.inner.update(&mut self.amount); **Anti-patterns:** public mutable fields, eager initialization, not clearing invalid data +## Message Display + +User-facing messages use `MessageBanner` (`src/ui/components/message_banner.rs`). Global banners are rendered centrally by `island_central_panel()` — `AppState::update()` sets them automatically for backend task results. Screens only override `display_message()` for side-effects. See the component's doc comments and `docs/ai-design/2026-02-17-unified-messages/` for details. + ## Database Single SQLite connection wrapped in `Mutex`. Schema initialized in `database/initialization.rs`. Domain modules provide typed CRUD methods. Backend task errors are `Result` — string errors display directly to users. diff --git a/doc/COMPONENT_DESIGN_PATTERN.md b/doc/COMPONENT_DESIGN_PATTERN.md index d1a6d73df..0c247a073 100644 --- a/doc/COMPONENT_DESIGN_PATTERN.md +++ b/doc/COMPONENT_DESIGN_PATTERN.md @@ -98,3 +98,25 @@ impl ComponentResponse for MyComponentResponse { - Not clearing invalid data See `AmountInput` in `src/ui/components/amount_input.rs` for a complete example. + +--- + +## Display-Only Components + +Not all components produce domain values. **Display-only** components like `MessageBanner`, `StyledButton`, `GradientHeading`, and `InfoPopup` consume data for rendering and do not implement the `Component` trait. They follow the same structural conventions (private fields, `new()` constructor, builder methods, `show()`) but omit `ComponentResponse` and `update()`. + +### Global State via egui Context + +`MessageBanner` demonstrates a pattern for **app-wide UI state** stored in egui context data rather than in a screen struct. This is useful when the same widget must be accessible from multiple call sites (e.g., `AppState::update()` sets it, `island_central_panel()` renders it). + +```rust +// Setting from anywhere with &egui::Context +MessageBanner::set_global(ctx, "Error message", MessageType::Error); + +// Rendering from a central location +MessageBanner::show_global(ui); +``` + +Use this pattern sparingly. Per-screen ownership (the standard lazy-init pattern above) is preferred for most components. Global state is appropriate only when the rendering site and the state-setting site are architecturally separate (e.g., task result handling vs. screen rendering). + +See `src/ui/components/message_banner.rs` for the implementation. diff --git a/docs/ai-design/2026-02-17-unified-messages/architecture.md b/docs/ai-design/2026-02-17-unified-messages/architecture.md index c9afa675e..b51d5af16 100644 --- a/docs/ai-design/2026-02-17-unified-messages/architecture.md +++ b/docs/ai-design/2026-02-17-unified-messages/architecture.md @@ -744,3 +744,91 @@ Add an accessor method to the trait so `AppState` could call `screen.banner().se 8 screens already use `DateTime` for timed messages. **Rejected because:** `Instant` is monotonic (immune to system clock changes), simpler (no chrono dependency needed), and purpose-built for duration measurement. `DateTime` is correct for display timestamps but wrong for timeout logic. + +--- + +## 9. Implementation Notes + +This section documents how the implementation diverged from the original design and the patterns that emerged during development. + +### 9.1 Architecture Deviation: Global Banner Instead of Per-Screen + +The original design (Sections 2-7 above) specified **per-screen ownership** where each screen holds a `MessageBanner` instance. The implementation instead uses a **global banner** stored in egui context data and rendered centrally by `island_central_panel()`. + +**Why the change:** The user requested centralized error handling where backend task errors display automatically without requiring every screen to wire up `display_message()` overrides. With a global banner, `AppState::update()` sets the message once and it appears on whatever screen is currently visible. No per-screen rendering code is needed for basic error/success display. + +**Why the original concern was resolved:** Section 8, Alternative A rejected the global approach because "screens render inside panels with different layouts" and "a global banner can't know the correct placement." In practice, all 58 screens render through `island_central_panel()` (in `src/ui/components/styled.rs`), which provides a single consistent insertion point at the top of the content area, before screen content. This makes the placement concern moot. + +### 9.2 Global API + +The `MessageBanner` component (`src/ui/components/message_banner.rs`) exposes four static methods for global state management. State is stored in egui's temporary context data using `egui::Id::new("__global_message_banner")`. + +| Method | Signature | Purpose | +|--------|-----------|---------| +| `set_global` | `(ctx: &egui::Context, text: &str, message_type: MessageType)` | Sets or replaces the global banner message. Empty text clears. | +| `clear_global` | `(ctx: &egui::Context)` | Removes the global banner state from context data. | +| `show_global` | `(ui: &mut egui::Ui)` | Renders the banner, handles auto-dismiss and countdown. Called by `island_central_panel`. | +| `has_global` | `(ctx: &egui::Context) -> bool` | Checks whether a global message exists (useful for tests). | + +The underlying `BannerState` struct is `#[derive(Clone)]` because egui context data requires `Clone`. It stores `text: String`, `message_type: MessageType`, and `created_at: Instant`. + +### 9.3 AppState Integration + +`AppState::update()` in `src/app.rs` sets the global banner automatically for all task results: + +``` +TaskResult::Error(message) + -> MessageBanner::set_global(ctx, &message, MessageType::Error) + -> screen.display_message(&message, MessageType::Error) // for side-effects + +TaskResult::Success (default branch) + -> MessageBanner::set_global(ctx, "Success", MessageType::Success) + -> screen.display_task_result(result) + +TaskResult::Success (specific: UpdatedThemePreference) + -> MessageBanner::set_global(ctx, "Theme preference updated successfully", MessageType::Success) + +TaskResult::Success (specific: CastScheduledVote) + -> MessageBanner::set_global(ctx, "Successfully cast scheduled vote", MessageType::Success) +``` + +The call to `screen.display_message()` is retained alongside `set_global` so that screens can still perform side-effects (e.g., resetting step state on error) without being responsible for rendering. + +### 9.4 Screen Migration Pattern + +Migrated screens follow this pattern: + +1. **Remove error display fields** (`error_message: Option`, `backend_message: Option<(String, MessageType, DateTime)>`, etc.) +2. **Remove rendering code** (the `Frame`/`colored_label` blocks in `ui()`) +3. **Simplify `display_message()`** to only retain side-effect logic (e.g., resetting step state). The method no longer needs to store the error text since the global banner handles display. +4. **Add `pending_banner_message` field** for messages that originate in `display_task_result()`, which does not receive the egui `Context`. These are flushed at the top of `ui()`: + +```rust +// In display_task_result(): +self.pending_banner_message = Some(("Successfully refreshed identity".to_string(), MessageType::Success)); + +// At the top of ui(): +if let Some((text, msg_type)) = self.pending_banner_message.take() { + MessageBanner::set_global(ctx, &text, msg_type); +} +``` + +For validation errors set during `ui()`, screens call `MessageBanner::set_global(ctx, ...)` directly since they have the egui `Context`. + +### 9.5 Per-Instance API + +The `MessageBanner` struct with per-instance methods (`new()`, `set_message()`, `clear()`, `show()`, `has_message()`) still exists alongside the global API. It shares rendering logic with the global path via a private `render_banner()` function. + +This per-instance API is available for potential future use cases such as screen-local banners displayed alongside the global one (e.g., a screen that needs a secondary notification area). Currently no screens use the per-instance API; its methods carry `#[allow(dead_code)]`. + +### 9.6 Migration Status + +3 of ~50 screens have been migrated as a proof-of-concept, each demonstrating a different migration path: + +| Screen | Pattern | Migration Notes | +|--------|---------|-----------------| +| `TopUpIdentityScreen` | A (Framed Banner + Dismiss) | Removed `error_message` field and 20-line Frame rendering block. `display_message()` retains step-reset side-effect. | +| `IdentitiesScreen` | B (Timed Badge with DateTime) | Removed `backend_message` field, `check_message_expiration()`, and `dismiss_message()` helpers. Uses `pending_banner_message` for messages from `display_task_result()`. | +| `MintTokensScreen` | D (Bare colored_label + status enum) | Changed `MintTokensStatus::ErrorMessage(String)` to `MintTokensStatus::Error` (no payload). Uses `pending_banner_message` for validation errors set during construction and in `confirmation_ok()`. | + +The remaining ~47 screens continue to use their existing per-screen error rendering. Old and new patterns coexist without conflict because the global banner renders above screen content via `island_central_panel`. diff --git a/src/app.rs b/src/app.rs index c11d96189..b09102ba6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,6 +11,7 @@ use crate::context::connection_status::ConnectionStatus; use crate::database::Database; use crate::logging::initialize_logger; use crate::model::settings::Settings; +use crate::ui::components::MessageBanner; use crate::ui::contracts_documents::contracts_documents_screen::DocumentQueryScreen; use crate::ui::dashpay::{DashPayScreen, DashPaySubscreen, ProfileSearchScreen}; use crate::ui::dpns::dpns_contested_names_screen::{ @@ -192,6 +193,7 @@ impl AppState { password_info.clone(), subtasks.clone(), connection_status.clone(), + ctx.clone(), ) { Some(context) => context, None => { @@ -207,6 +209,7 @@ impl AppState { password_info.clone(), subtasks.clone(), connection_status.clone(), + ctx.clone(), ); let devnet_app_context = AppContext::new( Network::Devnet, @@ -214,6 +217,7 @@ impl AppState { password_info.clone(), subtasks.clone(), connection_status.clone(), + ctx.clone(), ); let local_app_context = AppContext::new( Network::Regtest, @@ -221,6 +225,7 @@ impl AppState { password_info, subtasks.clone(), connection_status.clone(), + ctx.clone(), ); // load fonts @@ -827,6 +832,11 @@ impl App for AppState { } BackendTaskSuccessResult::UpdatedThemePreference(new_theme) => { self.theme_preference = new_theme; + MessageBanner::set_global( + ctx, + "Theme preference updated successfully", + MessageType::Success, + ); self.visible_screen_mut().display_message( "Theme preference updated successfully", MessageType::Success, @@ -837,6 +847,11 @@ impl App for AppState { vote.voter_id.as_slice(), vote.contested_name.clone(), ); + MessageBanner::set_global( + ctx, + "Successfully cast scheduled vote", + MessageType::Success, + ); self.visible_screen_mut().display_message( "Successfully cast scheduled vote", MessageType::Success, @@ -844,12 +859,14 @@ impl App for AppState { self.visible_screen_mut().refresh(); } _ => { + MessageBanner::set_global(ctx, "Success", MessageType::Success); self.visible_screen_mut() .display_task_result(unboxed_message); } } } TaskResult::Error(message) => { + MessageBanner::set_global(ctx, &message, MessageType::Error); self.visible_screen_mut() .display_message(&message, MessageType::Error); } diff --git a/src/context/mod.rs b/src/context/mod.rs index 3ccaf835d..0abf915fd 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -97,6 +97,9 @@ pub struct AppContext { /// Cached fee multiplier permille from current epoch (1000 = 1x, 2000 = 2x) /// Updated when epoch info is fetched from Platform fee_multiplier_permille: AtomicU64, + /// The egui context, stored for use in non-UI code paths (e.g. display_task_result). + /// Clone is O(1) — egui::Context is Arc-backed and the same instance for the app lifetime. + egui_ctx: egui::Context, } impl AppContext { @@ -106,6 +109,7 @@ impl AppContext { password_info: Option, subtasks: Arc, connection_status: Arc, + egui_ctx: egui::Context, ) -> Option> { let config = match Config::load() { Ok(config) => config, @@ -270,6 +274,7 @@ impl AppContext { fee_multiplier_permille: AtomicU64::new( PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE, ), + egui_ctx, }; let app_context = Arc::new(app_context); @@ -341,6 +346,10 @@ impl AppContext { &self.connection_status } + pub fn egui_ctx(&self) -> &egui::Context { + &self.egui_ctx + } + pub fn set_core_backend_mode(self: &Arc, mode: CoreBackendMode) { self.core_backend_mode .store(mode.as_u8(), Ordering::Relaxed); diff --git a/src/ui/components/message_banner.rs b/src/ui/components/message_banner.rs new file mode 100644 index 000000000..b7e4d915b --- /dev/null +++ b/src/ui/components/message_banner.rs @@ -0,0 +1,480 @@ +use crate::ui::MessageType; +use crate::ui::components::component_trait::{Component, ComponentResponse}; +use crate::ui::theme::{DashColors, Shape, Spacing, Typography}; +use egui::InnerResponse; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +const DEFAULT_AUTO_DISMISS: Duration = Duration::from_secs(5); +const MAX_BANNERS: usize = 5; +const BANNER_STATE_ID: &str = "__global_message_banner"; + +/// Monotonic counter for generating unique banner keys. +static BANNER_KEY_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn next_banner_key() -> u64 { + BANNER_KEY_COUNTER.fetch_add(1, Ordering::Relaxed) +} + +/// The domain type for `MessageBanner`, representing the banner's lifecycle state. +#[derive(Clone, Debug, PartialEq)] +pub enum BannerStatus { + /// The banner is currently visible. + Visible, + /// The user clicked the dismiss button. + Dismissed, + /// The banner expired via auto-dismiss. + TimedOut, +} + +/// Response returned by `MessageBanner::show()` via the `Component` trait. +#[derive(Clone)] +pub struct MessageBannerResponse { + pub status: Option, + changed: bool, +} + +impl ComponentResponse for MessageBannerResponse { + type DomainType = BannerStatus; + + fn has_changed(&self) -> bool { + self.changed + } + + fn is_valid(&self) -> bool { + true + } + + fn changed_value(&self) -> &Option { + &self.status + } + + fn error_message(&self) -> Option<&str> { + None + } +} + +#[derive(Clone)] +struct BannerState { + key: u64, + text: String, + message_type: MessageType, + created_at: Instant, + /// When `Some`, the banner auto-dismisses after this duration. + /// `None` means the banner persists until manually dismissed. + auto_dismiss_after: Option, + /// When `true`, display elapsed time since creation instead of countdown. + show_elapsed: bool, +} + +/// Handle for a global banner, returned by [`MessageBanner::set_global`] and +/// [`MessageBanner::replace_global`]. Identifies the banner by an internal key, +/// so the display text can be updated without losing the reference. +/// +/// The handle is `'static` and safe to store. Methods that modify the banner +/// (`set_text`, `with_auto_dismiss`) take `&self` so the handle can be reused. +pub struct BannerHandle { + ctx: egui::Context, + key: u64, +} + +impl BannerHandle { + /// Returns how long ago this banner was created, looked up from context data. + /// Returns `None` if the banner no longer exists. + pub fn elapsed(&self) -> Option { + let banners = get_banners(&self.ctx); + banners + .iter() + .find(|b| b.key == self.key) + .map(|b| b.created_at.elapsed()) + } + + /// Update the display text of this banner. + /// Returns `None` if the banner no longer exists. + pub fn set_message(&self, text: &str) -> Option<&Self> { + let mut banners = get_banners(&self.ctx); + let found = if let Some(b) = banners.iter_mut().find(|b| b.key == self.key) { + b.text = text.to_string(); + true + } else { + false + }; + set_banners(&self.ctx, banners); + found.then_some(self) + } + + /// Override the auto-dismiss duration for this banner. + /// Resets the countdown timer. + /// Returns `None` if the banner no longer exists. + pub fn with_auto_dismiss(&self, duration: Duration) -> Option<&Self> { + let mut banners = get_banners(&self.ctx); + let found = if let Some(b) = banners.iter_mut().find(|b| b.key == self.key) { + b.auto_dismiss_after = Some(duration); + b.created_at = Instant::now(); + true + } else { + false + }; + set_banners(&self.ctx, banners); + found.then_some(self) + } + + /// Enable elapsed-time display on this banner. Disables auto-dismiss + /// and shows how long the banner has been visible (e.g. for long-running operations). + /// Returns `None` if the banner no longer exists. + pub fn with_elapsed(&self) -> Option<&Self> { + let mut banners = get_banners(&self.ctx); + let found = if let Some(b) = banners.iter_mut().find(|b| b.key == self.key) { + b.show_elapsed = true; + b.auto_dismiss_after = None; + true + } else { + false + }; + set_banners(&self.ctx, banners); + found.then_some(self) + } + + /// Remove this banner immediately. + pub fn clear(self) { + let mut banners = get_banners(&self.ctx); + banners.retain(|b| b.key != self.key); + set_banners(&self.ctx, banners); + } +} + +/// A banner widget for displaying screen-level messages. +/// +/// Supports two modes: +/// - **Global**: State stored in egui context data, rendered by `island_central_panel`. +/// Use `set_global`, `clear_global_message`, `show_global`, `has_global`. +/// - **Per-instance**: Each screen owns a `MessageBanner` and calls `show()`. +/// +/// Follows component conventions: private fields, `new()` constructor, builder methods. +pub struct MessageBanner { + state: Option, +} + +impl MessageBanner { + /// Creates an empty banner (no message displayed). + pub fn new() -> Self { + Self { state: None } + } + + /// Sets or replaces the current message. Resets the auto-dismiss timer. + /// An empty string is treated as a clear operation. + pub fn set_message(&mut self, text: &str, message_type: MessageType) -> &mut Self { + if text.is_empty() { + self.state = None; + } else { + self.state = Some(BannerState { + key: next_banner_key(), + text: text.to_string(), + message_type, + created_at: Instant::now(), + auto_dismiss_after: default_auto_dismiss(message_type), + show_elapsed: false, + }); + } + self + } + + /// Override the auto-dismiss duration for the current message. + /// Resets the countdown timer. No-op if no message is set. + #[allow(dead_code)] + pub fn set_auto_dismiss(&mut self, duration: Duration) -> &mut Self { + if let Some(state) = &mut self.state { + state.auto_dismiss_after = Some(duration); + state.created_at = Instant::now(); + } + self + } + + /// Clears the current message immediately. + #[allow(dead_code)] + pub fn clear(&mut self) { + self.state = None; + } + + /// Returns whether a message is currently displayed. + pub fn has_message(&self) -> bool { + self.state.is_some() + } + + // -- Global API (egui context data) -- + // + // Multiple messages can be displayed simultaneously. Messages are + // deduplicated by text — calling `set_global` with the same text twice + // results in a single banner. Use `replace_global` to swap one message + // for another (e.g., replacing a generic "Success" with a specific one). + + /// Adds a global banner message if one with the same text does not already exist. + /// Evicts the oldest message when the cap ([`MAX_BANNERS`]) is reached. + /// + /// Returns a [`BannerHandle`] for updating or clearing the banner later. + pub fn set_global(ctx: &egui::Context, text: &str, message_type: MessageType) -> BannerHandle { + let mut banners = get_banners(ctx); + if let Some(existing) = banners.iter().find(|b| b.text == text) { + let key = existing.key; + return BannerHandle { + ctx: ctx.clone(), + key, + }; + } + let key = next_banner_key(); + if !text.is_empty() { + banners.push(BannerState { + key, + text: text.to_string(), + message_type, + created_at: Instant::now(), + auto_dismiss_after: default_auto_dismiss(message_type), + show_elapsed: false, + }); + if banners.len() > MAX_BANNERS { + banners.remove(0); + } + set_banners(ctx, banners); + } + BannerHandle { + ctx: ctx.clone(), + key, + } + } + + /// Finds a message by `old_text` and replaces it with `new_text`. + /// If `old_text` is not found, adds `new_text` as a new message (with dedup check). + /// + /// Returns a [`BannerHandle`] for updating or clearing the banner later. + pub fn replace_global( + ctx: &egui::Context, + old_text: &str, + new_text: &str, + message_type: MessageType, + ) -> BannerHandle { + if new_text.is_empty() { + Self::clear_global_message(ctx, old_text); + return BannerHandle { + ctx: ctx.clone(), + key: next_banner_key(), + }; + } + let mut banners = get_banners(ctx); + let key; + if let Some(b) = banners.iter_mut().find(|b| b.text == old_text) { + key = b.key; + b.text = new_text.to_string(); + b.message_type = message_type; + b.created_at = Instant::now(); + b.auto_dismiss_after = default_auto_dismiss(message_type); + } else if let Some(existing) = banners.iter().find(|b| b.text == new_text) { + key = existing.key; + } else { + key = next_banner_key(); + banners.push(BannerState { + key, + text: new_text.to_string(), + message_type, + created_at: Instant::now(), + auto_dismiss_after: default_auto_dismiss(message_type), + show_elapsed: false, + }); + if banners.len() > MAX_BANNERS { + banners.remove(0); + } + } + set_banners(ctx, banners); + BannerHandle { + ctx: ctx.clone(), + key, + } + } + + /// Clears the specific global banner message matching `text`. + pub fn clear_global_message(ctx: &egui::Context, text: &str) { + let mut banners = get_banners(ctx); + banners.retain(|b| b.text != text); + set_banners(ctx, banners); + } + + /// Returns whether any global banner messages exist. + #[allow(dead_code)] + pub fn has_global(ctx: &egui::Context) -> bool { + !get_banners(ctx).is_empty() + } + + /// Renders all global banners from egui context data. + /// Call inside `island_central_panel` before content. + pub fn show_global(ui: &mut egui::Ui) { + let mut banners = get_banners(ui.ctx()); + if banners.is_empty() { + return; + } + banners.retain(|b| process_banner(ui, b) == BannerStatus::Visible); + set_banners(ui.ctx(), banners); + } +} + +impl Default for MessageBanner { + fn default() -> Self { + Self::new() + } +} + +impl Component for MessageBanner { + type DomainType = BannerStatus; + type Response = MessageBannerResponse; + + fn show(&mut self, ui: &mut egui::Ui) -> InnerResponse { + let Some(state) = &self.state else { + return empty_response(ui); + }; + let status = process_banner(ui, state); + if status != BannerStatus::Visible { + self.state = None; + } + let changed = status != BannerStatus::Visible; + InnerResponse::new( + MessageBannerResponse { + status: Some(status), + changed, + }, + ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()), + ) + } + + fn current_value(&self) -> Option { + if self.state.is_some() { + Some(BannerStatus::Visible) + } else { + None + } + } +} + +/// Returns the default auto-dismiss duration for a message type. +/// `Success` and `Info` auto-dismiss; `Error` and `Warning` persist. +fn default_auto_dismiss(message_type: MessageType) -> Option { + match message_type { + MessageType::Success | MessageType::Info => Some(DEFAULT_AUTO_DISMISS), + MessageType::Error | MessageType::Warning => None, + } +} + +/// Helper for the empty-state return in `Component::show()`. +fn empty_response(ui: &mut egui::Ui) -> InnerResponse { + InnerResponse::new( + MessageBannerResponse { + status: None, + changed: false, + }, + ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()), + ) +} + +/// Processes a single banner: checks expiry, renders, handles dismiss, requests repaint. +/// Returns the banner's resulting status. +fn process_banner(ui: &mut egui::Ui, state: &BannerState) -> BannerStatus { + let elapsed = state.created_at.elapsed(); + + // Check auto-dismiss expiry + if let Some(duration) = state.auto_dismiss_after + && elapsed >= duration + { + return BannerStatus::TimedOut; + } + + // Compute the right-side time annotation + let annotation = if state.show_elapsed { + Some(format!("({}s)", elapsed.as_secs())) + } else if let Some(duration) = state.auto_dismiss_after { + let remaining = duration.saturating_sub(elapsed); + Some(format!("({}s)", remaining.as_secs() + 1)) + } else { + None + }; + + if render_banner(ui, &state.text, state.message_type, annotation.as_deref()) { + return BannerStatus::Dismissed; + } + if state.auto_dismiss_after.is_some() || state.show_elapsed { + ui.ctx().request_repaint_after(Duration::from_secs(1)); + } + BannerStatus::Visible +} + +/// Shared rendering logic for both global and per-instance banners. +/// Returns `true` if the dismiss button was clicked. +fn render_banner( + ui: &mut egui::Ui, + text: &str, + message_type: MessageType, + annotation: Option<&str>, +) -> bool { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let fg_color = DashColors::message_color(message_type, dark_mode); + let bg_color = DashColors::message_background_color(message_type, dark_mode); + let secondary_color = DashColors::text_secondary(dark_mode); + + let icon = icon_for_type(message_type); + let mut dismissed = false; + + egui::Frame::new() + .fill(bg_color) + .inner_margin(egui::Margin::symmetric(10, 8)) + .corner_radius(Shape::RADIUS_SM as f32) + .stroke(egui::Stroke::new(Shape::BORDER_WIDTH, fg_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + // Icon + ui.label(egui::RichText::new(icon).color(fg_color).strong()); + ui.add_space(Spacing::XS); + + // Message text + ui.label(egui::RichText::new(text).color(fg_color)); + + // Right-aligned: annotation + dismiss + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("x").clicked() { + dismissed = true; + } + + if let Some(annotation) = annotation { + ui.label( + egui::RichText::new(annotation) + .font(Typography::body_small()) + .color(secondary_color), + ); + } + }); + }); + }); + ui.add_space(Spacing::SM); + + dismissed +} + +/// Reads the global banner list from egui context data. +fn get_banners(ctx: &egui::Context) -> Vec { + ctx.data(|d| d.get_temp::>(egui::Id::new(BANNER_STATE_ID))) + .unwrap_or_default() +} + +/// Writes the global banner list to egui context data. +/// Removes the entry entirely when the list is empty. +fn set_banners(ctx: &egui::Context, banners: Vec) { + if banners.is_empty() { + ctx.data_mut(|d| d.remove::>(egui::Id::new(BANNER_STATE_ID))); + } else { + ctx.data_mut(|d| d.insert_temp(egui::Id::new(BANNER_STATE_ID), banners)); + } +} + +fn icon_for_type(message_type: MessageType) -> &'static str { + match message_type { + MessageType::Error => "\u{26A0}", // warning sign + MessageType::Warning => "\u{26A0}", // warning sign (differentiated by color) + MessageType::Success => "\u{2713}", // check mark + MessageType::Info => "\u{2139}", // info + } +} diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index d25b6bdcc..1c71238c4 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -9,6 +9,7 @@ pub mod identity_selector; pub mod info_popup; pub mod left_panel; pub mod left_wallet_panel; +pub mod message_banner; pub mod styled; pub mod tokens_subscreen_chooser_panel; pub mod tools_subscreen_chooser_panel; @@ -18,3 +19,4 @@ pub mod wallet_unlock_popup; // Re-export the main traits for easy access pub use component_trait::{Component, ComponentResponse}; +pub use message_banner::{BannerHandle, BannerStatus, MessageBanner, MessageBannerResponse}; diff --git a/src/ui/components/styled.rs b/src/ui/components/styled.rs index 3d0a2fbb9..a4821c763 100644 --- a/src/ui/components/styled.rs +++ b/src/ui/components/styled.rs @@ -560,7 +560,10 @@ pub fn island_central_panel(ctx: &Context, content: impl FnOnce(&mut Ui) -> R .inner_margin(Margin::same(inner_margin as i8)) .corner_radius(egui::CornerRadius::same(Shape::RADIUS_LG)) .shadow(Shadow::elevated()) - .show(ui, |ui| content(ui)) + .show(ui, |ui| { + super::MessageBanner::show_global(ui); + content(ui) + }) .inner }) .inner diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index 8e063c0d2..693663bae 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -11,6 +11,7 @@ use crate::model::wallet::WalletSeedHash; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::{BannerHandle, MessageBanner}; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::identities::register_dpns_name_screen::{ @@ -20,7 +21,6 @@ use crate::ui::identities::top_up_identity_screen::TopUpIdentityScreen; use crate::ui::identities::transfer_screen::TransferScreen; use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike, ScreenType}; -use chrono::{DateTime, Utc}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::{Purpose, SecurityLevel}; @@ -52,12 +52,6 @@ enum IdentitiesSortOrder { Descending, } -#[derive(PartialEq)] -enum IdentitiesRefreshingStatus { - Refreshing(u64), - NotRefreshing, -} - pub struct IdentitiesScreen { pub identities: Arc>>, pub app_context: Arc, @@ -66,8 +60,7 @@ pub struct IdentitiesScreen { sort_column: IdentitiesSortColumn, sort_order: IdentitiesSortOrder, use_custom_order: bool, - refreshing_status: IdentitiesRefreshingStatus, - backend_message: Option<(String, MessageType, DateTime)>, + refresh_banner: Option, // Alias editing state editing_alias_identity: Option, editing_alias_value: String, @@ -92,8 +85,7 @@ impl IdentitiesScreen { sort_column: IdentitiesSortColumn::Alias, sort_order: IdentitiesSortOrder::Ascending, use_custom_order: true, - refreshing_status: IdentitiesRefreshingStatus::NotRefreshing, - backend_message: None, + refresh_banner: None, editing_alias_identity: None, editing_alias_value: String::new(), }; @@ -447,7 +439,7 @@ impl IdentitiesScreen { /// Returns Some `AppAction` if any identity needs to be refreshed, /// otherwise returns None. pub fn ensure_identities_status(&self, identities: &[QualifiedIdentity]) -> Option { - if self.refreshing_status != IdentitiesRefreshingStatus::NotRefreshing { + if self.refresh_banner.is_some() { // avoid refresh loop return None; } @@ -1068,22 +1060,6 @@ impl IdentitiesScreen { AppAction::None } - - fn dismiss_message(&mut self) { - self.backend_message = None; - } - - fn check_message_expiration(&mut self) { - if let Some((_, _, timestamp)) = &self.backend_message { - let now = Utc::now(); - let elapsed = now.signed_duration_since(*timestamp); - - // Automatically dismiss the message after 10 seconds - if elapsed.num_seconds() >= 10 { - self.dismiss_message(); - } - } - } } impl ScreenLike for IdentitiesScreen { @@ -1105,11 +1081,13 @@ impl ScreenLike for IdentitiesScreen { } } - fn display_message(&mut self, message: &str, message_type: crate::ui::MessageType) { - if let crate::ui::MessageType::Error = message_type { - self.refreshing_status = IdentitiesRefreshingStatus::NotRefreshing; + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + if let MessageType::Error = message_type + && let Some(handle) = self.refresh_banner.take() + { + handle.clear(); } - self.backend_message = Some((message.to_string(), message_type, Utc::now())); } fn display_task_result( @@ -1119,18 +1097,18 @@ impl ScreenLike for IdentitiesScreen { if let crate::ui::BackendTaskSuccessResult::RefreshedIdentity(_) = backend_task_success_result { - self.refreshing_status = IdentitiesRefreshingStatus::NotRefreshing; - self.backend_message = Some(( - "Successfully refreshed identity".to_string(), - crate::ui::MessageType::Success, - Utc::now(), - )); + if let Some(handle) = self.refresh_banner.take() { + handle.clear(); + } + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Successfully refreshed identity", + MessageType::Success, + ); } } fn ui(&mut self, ctx: &Context) -> AppAction { - self.check_message_expiration(); - let mut right_buttons = if !self.app_context.has_wallet.load(Ordering::Relaxed) { vec![ ( @@ -1202,54 +1180,19 @@ impl ScreenLike for IdentitiesScreen { inner_action |= self.show_alias_edit_popup(ctx); } - // Show either refreshing indicator or message, but not both - if let IdentitiesRefreshingStatus::Refreshing(start_time) = self.refreshing_status { - ui.add_space(25.0); // Space above - let now = Utc::now().timestamp() as u64; - let elapsed = now - start_time; - ui.horizontal(|ui| { - ui.add_space(10.0); - ui.label(format!("Refreshing... Time taken so far: {}", elapsed)); - ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); - }); - ui.add_space(2.0); // Space below - } else if let Some((message, message_type, timestamp)) = self.backend_message.clone() { - let dark_mode = ui.ctx().style().visuals.dark_mode; - let message_color = match message_type { - MessageType::Error => egui::Color32::DARK_RED, - MessageType::Info => DashColors::text_primary(dark_mode), - MessageType::Success => egui::Color32::DARK_GREEN, - }; - - ui.add_space(25.0); // Same space as refreshing indicator - ui.horizontal(|ui| { - ui.add_space(10.0); - - // Calculate remaining seconds - let now = Utc::now(); - let elapsed = now.signed_duration_since(timestamp); - let remaining = (10 - elapsed.num_seconds()).max(0); - - // Add the message with auto-dismiss countdown - let full_msg = format!("{} ({}s)", message, remaining); - ui.label(egui::RichText::new(full_msg).color(message_color)); - }); - ui.add_space(2.0); // Same space below as refreshing indicator - } inner_action }); match action { - AppAction::BackendTask(BackendTask::IdentityTask(IdentityTask::RefreshIdentity(_))) => { - self.refreshing_status = - IdentitiesRefreshingStatus::Refreshing(Utc::now().timestamp() as u64); - self.backend_message = None // Clear any existing message - } - AppAction::BackendTasks(_, _) => { - // Going to assume this is only going to be Refresh All - self.refreshing_status = - IdentitiesRefreshingStatus::Refreshing(Utc::now().timestamp() as u64); - self.backend_message = None // Clear any existing message + AppAction::BackendTask(BackendTask::IdentityTask(IdentityTask::RefreshIdentity(_))) + | AppAction::BackendTasks(_, _) => { + if let Some(handle) = self.refresh_banner.take() { + handle.clear(); + } + let handle = + MessageBanner::set_global(ctx, "Refreshing identities...", MessageType::Info); + handle.with_elapsed(); + self.refresh_banner = Some(handle); } _ => {} } diff --git a/src/ui/identities/top_up_identity_screen/by_platform_address.rs b/src/ui/identities/top_up_identity_screen/by_platform_address.rs index b10723693..59d2f21fb 100644 --- a/src/ui/identities/top_up_identity_screen/by_platform_address.rs +++ b/src/ui/identities/top_up_identity_screen/by_platform_address.rs @@ -4,6 +4,8 @@ use crate::backend_task::identity::IdentityTask; use crate::model::amount::Amount; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::wallet::WalletSeedHash; +use crate::ui::MessageType; +use crate::ui::components::MessageBanner; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::component_trait::{Component, ComponentResponse}; use crate::ui::identities::funding_common::WalletFundedScreenStep; @@ -199,7 +201,7 @@ impl TopUpIdentityScreen { action = top_up_action; } Err(e) => { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } } } diff --git a/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs b/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs index ed562bcfa..f4854c908 100644 --- a/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs +++ b/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs @@ -129,24 +129,20 @@ impl TopUpIdentityScreen { .frame(true) .corner_radius(3.0); if ui.add(button).clicked() { - self.error_message = None; action |= self.top_up_identity_clicked(FundingMethod::UseUnusedAssetLock); } ui.add_space(20.0); - // Only show status messages if there's no error - if self.error_message.is_none() { - ui.vertical_centered(|ui| match step { - WalletFundedScreenStep::WaitingForPlatformAcceptance => { - ui.heading("=> Waiting for Platform acknowledgement <="); - } - WalletFundedScreenStep::Success => { - ui.heading("...Success..."); - } - _ => {} - }); - } + ui.vertical_centered(|ui| match step { + WalletFundedScreenStep::WaitingForPlatformAcceptance => { + ui.heading("=> Waiting for Platform acknowledgement <="); + } + WalletFundedScreenStep::Success => { + ui.heading("...Success..."); + } + _ => {} + }); ui.add_space(40.0); action diff --git a/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs b/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs index d2242f8b2..5f1227baa 100644 --- a/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs +++ b/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs @@ -82,31 +82,27 @@ impl TopUpIdentityScreen { .frame(true) .corner_radius(3.0); if ui.add(button).clicked() { - self.error_message = None; action = self.top_up_identity_clicked(FundingMethod::UseWalletBalance); } ui.add_space(20.0); - // Only show status messages if there's no error - if self.error_message.is_none() { - ui.vertical_centered(|ui| { - match step { - WalletFundedScreenStep::WaitingForAssetLock => { - ui.heading( - "=> Waiting for Core Chain to produce proof of transfer of funds. <=", - ); - } - WalletFundedScreenStep::WaitingForPlatformAcceptance => { - ui.heading("=> Waiting for Platform acknowledgement <="); - } - WalletFundedScreenStep::Success => { - ui.heading("...Success..."); - } - _ => {} - }; - }); - } + ui.vertical_centered(|ui| { + match step { + WalletFundedScreenStep::WaitingForAssetLock => { + ui.heading( + "=> Waiting for Core Chain to produce proof of transfer of funds. <=", + ); + } + WalletFundedScreenStep::WaitingForPlatformAcceptance => { + ui.heading("=> Waiting for Platform acknowledgement <="); + } + WalletFundedScreenStep::Success => { + ui.heading("...Success..."); + } + _ => {} + }; + }); ui.add_space(40.0); action diff --git a/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs b/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs index 12339a127..4c916473f 100644 --- a/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs +++ b/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs @@ -1,6 +1,8 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; use crate::backend_task::identity::{IdentityTask, IdentityTopUpInfo, TopUpIdentityFundingMethod}; +use crate::ui::MessageType; +use crate::ui::components::MessageBanner; use crate::ui::identities::funding_common::{self, copy_to_clipboard, generate_qr_code_image}; use crate::ui::identities::top_up_identity_screen::{TopUpIdentityScreen, WalletFundedScreenStep}; use dash_sdk::dashcore_rpc::RpcApi; @@ -126,7 +128,7 @@ impl TopUpIdentityScreen { if let Ok(amount_dash) = self.funding_amount.parse::() { if amount_dash > 0.0 { if let Err(e) = self.render_qr_code(ui, amount_dash) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } } else { ui.label("Please enter an amount greater than 0"); @@ -173,25 +175,22 @@ impl TopUpIdentityScreen { } } - // Only show status messages if there's no error - if self.error_message.is_none() { - match step { - WalletFundedScreenStep::WaitingOnFunds => { - ui.heading("=> Waiting for funds. <="); - } - WalletFundedScreenStep::WaitingForAssetLock => { - ui.heading( - "=> Waiting for Core Chain to produce proof of transfer of funds. <=", - ); - } - WalletFundedScreenStep::WaitingForPlatformAcceptance => { - ui.heading("=> Waiting for Platform acknowledgement. <="); - } - WalletFundedScreenStep::Success => { - ui.heading("...Success..."); - } - _ => {} + match step { + WalletFundedScreenStep::WaitingOnFunds => { + ui.heading("=> Waiting for funds. <="); + } + WalletFundedScreenStep::WaitingForAssetLock => { + ui.heading( + "=> Waiting for Core Chain to produce proof of transfer of funds. <=", + ); + } + WalletFundedScreenStep::WaitingForPlatformAcceptance => { + ui.heading("=> Waiting for Platform acknowledgement. <="); + } + WalletFundedScreenStep::Success => { + ui.heading("...Success..."); } + _ => {} } AppAction::None }); diff --git a/src/ui/identities/top_up_identity_screen/mod.rs b/src/ui/identities/top_up_identity_screen/mod.rs index 0178ac738..19b63d588 100644 --- a/src/ui/identities/top_up_identity_screen/mod.rs +++ b/src/ui/identities/top_up_identity_screen/mod.rs @@ -13,6 +13,7 @@ use crate::model::amount::Amount; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::components::MessageBanner; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::component_trait::Component; use crate::ui::components::info_popup::InfoPopup; @@ -24,7 +25,6 @@ use crate::ui::components::wallet_unlock_popup::{ }; use crate::ui::identities::add_new_identity_screen::FundingMethod; use crate::ui::identities::funding_common::WalletFundedScreenStep; -use crate::ui::theme::DashColors; use crate::ui::{MessageType, ScreenLike}; use dash_sdk::dashcore_rpc::dashcore::Address; use dash_sdk::dashcore_rpc::dashcore::transaction::special_transaction::TransactionPayload; @@ -54,7 +54,6 @@ pub struct TopUpIdentityScreen { funding_amount_input: Option, funding_utxo: Option<(OutPoint, TxOut, Address)>, copied_to_clipboard: Option>, - error_message: Option, wallet_unlock_popup: WalletUnlockPopup, show_pop_up_info: Option, pub app_context: Arc, @@ -80,7 +79,6 @@ impl TopUpIdentityScreen { funding_amount_input: None, funding_utxo: None, copied_to_clipboard: None, - error_message: None, wallet_unlock_popup: WalletUnlockPopup::new(), show_pop_up_info: None, app_context: app_context.clone(), @@ -428,9 +426,9 @@ impl TopUpIdentityScreen { } impl ScreenLike for TopUpIdentityScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. if message_type == MessageType::Error { - self.error_message = Some(format!("Error topping up identity: {}", message)); // Reset step so UI is not stuck on waiting messages let mut step = self.step.write().unwrap(); if *step == WalletFundedScreenStep::WaitingForPlatformAcceptance @@ -438,8 +436,6 @@ impl ScreenLike for TopUpIdentityScreen { { *step = WalletFundedScreenStep::ReadyToCreate; } - } else { - self.error_message = Some(message.to_string()); } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { @@ -454,7 +450,6 @@ impl ScreenLike for TopUpIdentityScreen { self.funding_amount_exact = None; self.funding_amount_input = None; self.copied_to_clipboard = None; - self.error_message = None; let mut step = self.step.write().unwrap(); *step = WalletFundedScreenStep::Success; @@ -527,30 +522,6 @@ impl ScreenLike for TopUpIdentityScreen { action |= island_central_panel(ctx, |ui| { let mut inner_action = AppAction::None; - let _dark_mode = ui.ctx().style().visuals.dark_mode; - - // Display error message at the top, outside of scroll area - if let Some(error_message) = self.error_message.clone() { - let message_color = DashColors::ERROR; - - ui.horizontal(|ui| { - egui::Frame::new() - .fill(message_color.gamma_multiply(0.1)) - .inner_margin(egui::Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, message_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(egui::RichText::new(&error_message).color(message_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.error_message = None; - } - }); - }); - }); - ui.add_space(10.0); - } ScrollArea::vertical().show(ui, |ui| { let step = { *self.step.read().unwrap() }; @@ -640,7 +611,7 @@ impl ScreenLike for TopUpIdentityScreen { if let Some(wallet) = &self.wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if wallet_needs_unlock(wallet) { ui.add_space(10.0); diff --git a/src/ui/tokens/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs index 6a77fe0c0..3e96e83a1 100644 --- a/src/ui/tokens/mint_tokens_screen.rs +++ b/src/ui/tokens/mint_tokens_screen.rs @@ -7,6 +7,7 @@ use crate::model::amount::Amount; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::components::MessageBanner; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::component_trait::{Component, ComponentResponse}; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; @@ -48,7 +49,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; pub enum MintTokensStatus { NotStarted, WaitingForResult(u64), // Use seconds or millis - ErrorMessage(String), + Error, Complete, } @@ -68,7 +69,6 @@ pub struct MintTokensScreen { pub amount: Option, pub amount_input: Option, status: MintTokensStatus, - error_message: Option, /// Basic references pub app_context: Arc, @@ -100,7 +100,9 @@ impl MintTokensScreen { ) .cloned(); - let mut error_message = None; + let set_error_banner = |msg: &str| { + MessageBanner::set_global(app_context.egui_ctx(), msg, MessageType::Error); + }; let group = match identity_token_info .token_config @@ -108,32 +110,30 @@ impl MintTokensScreen { .authorized_to_make_change_action_takers() { AuthorizedActionTakers::NoOne => { - error_message = Some("Minting is not allowed on this token".to_string()); + set_error_banner("Minting is not allowed on this token"); None } AuthorizedActionTakers::ContractOwner => { if identity_token_info.data_contract.contract.owner_id() != identity_token_info.identity.identity.id() { - error_message = Some( - "You are not allowed to mint this token. Only the contract owner is." - .to_string(), + set_error_banner( + "You are not allowed to mint this token. Only the contract owner is.", ); } None } AuthorizedActionTakers::Identity(identifier) => { if identifier != &identity_token_info.identity.identity.id() { - error_message = Some("You are not allowed to mint this token".to_string()); + set_error_banner("You are not allowed to mint this token"); } None } AuthorizedActionTakers::MainGroup => { match identity_token_info.token_config.main_control_group() { None => { - error_message = Some( - "Invalid contract: No main control group, though one should exist" - .to_string(), + set_error_banner( + "Invalid contract: No main control group, though one should exist", ); None } @@ -145,7 +145,7 @@ impl MintTokensScreen { { Ok(group) => Some((group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -160,7 +160,7 @@ impl MintTokensScreen { { Ok(group) => Some((*group_pos, group.clone())), Err(e) => { - error_message = Some(format!("Invalid contract: {}", e)); + set_error_banner(&format!("Invalid contract: {}", e)); None } } @@ -183,12 +183,16 @@ impl MintTokensScreen { }; // Attempt to get an unlocked wallet reference + let mut wallet_error = None; let selected_wallet = get_selected_wallet( &identity_token_info.identity, None, possible_key.as_ref(), - &mut error_message, + &mut wallet_error, ); + if let Some(e) = wallet_error { + set_error_banner(&e); + } Self { identity_token_info, @@ -203,7 +207,6 @@ impl MintTokensScreen { amount: None, amount_input: None, status: MintTokensStatus::NotStarted, - error_message, app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, @@ -224,7 +227,7 @@ impl MintTokensScreen { // Check if input should be disabled when operation is in progress let enabled = match self.status { MintTokensStatus::WaitingForResult(_) | MintTokensStatus::Complete => false, - MintTokensStatus::NotStarted | MintTokensStatus::ErrorMessage(_) => true, + MintTokensStatus::NotStarted | MintTokensStatus::Error => true, }; let response = ui.add_enabled_ui(enabled, |ui| amount_input.show(ui)).inner; @@ -279,8 +282,12 @@ impl MintTokensScreen { fn confirmation_ok(&mut self) -> AppAction { if self.amount.is_none() || self.amount == Some(Amount::new(0, 0)) { - self.status = MintTokensStatus::ErrorMessage("Invalid amount".into()); - self.error_message = Some("Invalid amount".into()); + self.status = MintTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid amount", + MessageType::Error, + ); return AppAction::None; } @@ -293,8 +300,12 @@ impl MintTokensScreen { ); if parsed_receiver_id.is_err() { - self.status = MintTokensStatus::ErrorMessage("Invalid receiver".into()); - self.error_message = Some("Invalid receiver".into()); + self.status = MintTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Invalid receiver", + MessageType::Error, + ); return AppAction::None; } @@ -353,10 +364,10 @@ impl MintTokensScreen { } impl ScreenLike for MintTokensScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. if let MessageType::Error = message_type { - self.status = MintTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + self.status = MintTokensStatus::Error; } } @@ -487,7 +498,7 @@ impl ScreenLike for MintTokensScreen { // Possibly handle locked wallet scenario (similar to TransferTokens) if let Some(wallet) = &self.selected_wallet { if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); } if wallet_needs_unlock(wallet) { ui.add_space(10.0); @@ -704,11 +715,8 @@ impl ScreenLike for MintTokensScreen { let elapsed = now - start_time; ui.label(format!("Minting... elapsed: {} seconds", elapsed)); } - MintTokensStatus::ErrorMessage(msg) => { - ui.colored_label( - DashColors::error_color(dark_mode), - format!("Error: {}", msg), - ); + MintTokensStatus::Error => { + // Error display is handled by the global MessageBanner } MintTokensStatus::Complete => { // handled above diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index 6f6039a7a..5eee677c7 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -3248,6 +3248,7 @@ mod tests { None, Default::default(), Default::default(), + egui::Context::default(), ) .expect("Expected to create AppContext"); let mut token_creator_ui = TokensScreen::new(&app_context, TokensSubscreen::TokenCreator); @@ -3560,6 +3561,7 @@ mod tests { None, Default::default(), Default::default(), + egui::Context::default(), ) .expect("Expected to create AppContext"); let mut token_creator_ui = TokensScreen::new(&app_context, TokensSubscreen::TokenCreator); @@ -3686,6 +3688,7 @@ mod tests { None, Default::default(), Default::default(), + egui::Context::default(), ) .expect("Expected to create AppContext"); let mut token_creator_ui = TokensScreen::new(&app_context, TokensSubscreen::TokenCreator); diff --git a/src/ui/wallets/wallets_screen/dialogs.rs b/src/ui/wallets/wallets_screen/dialogs.rs index ccda62dd7..701fff7f7 100644 --- a/src/ui/wallets/wallets_screen/dialogs.rs +++ b/src/ui/wallets/wallets_screen/dialogs.rs @@ -15,6 +15,7 @@ use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use eframe::egui::{self, ComboBox, Context}; use eframe::epaint::TextureHandle; +use egui::Color32; use egui::load::SizedTexture; use egui::{Frame, Margin, RichText, TextureOptions}; use std::sync::{Arc, RwLock}; diff --git a/tests/kittest/main.rs b/tests/kittest/main.rs index 1562e7344..4a11e625f 100644 --- a/tests/kittest/main.rs +++ b/tests/kittest/main.rs @@ -1,5 +1,6 @@ mod create_asset_lock_screen; mod identities_screen; +mod message_banner; mod network_chooser; mod startup; mod wallets_screen; diff --git a/tests/kittest/message_banner.rs b/tests/kittest/message_banner.rs new file mode 100644 index 000000000..26db9c1d0 --- /dev/null +++ b/tests/kittest/message_banner.rs @@ -0,0 +1,443 @@ +use std::time::Duration; + +use dash_evo_tool::ui::MessageType; +use dash_evo_tool::ui::components::{BannerStatus, Component, ComponentResponse, MessageBanner}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +/// Test that show_global renders nothing and does not panic when no message is set. +#[test] +fn test_banner_renders_nothing_when_empty() { + let mut harness = Harness::builder() + .with_size(egui::vec2(400.0, 200.0)) + .build_ui(|ui| { + MessageBanner::show_global(ui); + }); + harness.run(); + // No labels should be present (empty banner renders nothing) + assert!(harness.query_by_label("x").is_none()); +} + +/// Test the global set/has/clear cycle using a standalone egui::Context. +#[test] +fn test_global_set_and_has() { + let ctx = egui::Context::default(); + + // Initially no global message + assert!(!MessageBanner::has_global(&ctx)); + + // Set a global error message + let handle = MessageBanner::set_global(&ctx, "Something went wrong", MessageType::Error); + assert!(MessageBanner::has_global(&ctx)); + assert!(handle.elapsed().is_some()); + + // Clear specific message + MessageBanner::clear_global_message(&ctx, "Something went wrong"); + assert!(!MessageBanner::has_global(&ctx)); + + // Handle should now report None for elapsed (banner gone) + assert!(handle.elapsed().is_none()); +} + +/// Test the per-instance set_message / has_message / clear cycle. +#[test] +fn test_instance_set_and_has() { + let mut banner = MessageBanner::new(); + + assert!(!banner.has_message()); + assert_eq!(banner.current_value(), None); + + banner.set_message("Error occurred", MessageType::Error); + assert!(banner.has_message()); + assert_eq!(banner.current_value(), Some(BannerStatus::Visible)); + + banner.clear(); + assert!(!banner.has_message()); + assert_eq!(banner.current_value(), None); +} + +/// Test that a global error message renders and the text appears. +#[test] +fn test_banner_renders_error_message() { + let message_text = "Critical failure detected"; + + let mut harness = Harness::builder() + .with_size(egui::vec2(600.0, 200.0)) + .build_ui(|ui| { + MessageBanner::set_global(ui.ctx(), message_text, MessageType::Error); + MessageBanner::show_global(ui); + }); + harness.run(); + // The message text and dismiss button should be present + assert!(harness.query_by_label(message_text).is_some()); + assert!(harness.query_by_label("x").is_some()); + // Error banners have no auto-dismiss countdown + assert!(harness.query_by_label_contains("s)").is_none()); +} + +/// Test that all four MessageType variants render with correct text and icon. +#[test] +fn test_banner_renders_all_types() { + let variants = [ + (MessageType::Error, "Error message", "\u{26A0}"), + (MessageType::Warning, "Warning message", "\u{26A0}"), + (MessageType::Success, "Success message", "\u{2713}"), + (MessageType::Info, "Info message", "\u{2139}"), + ]; + + for (msg_type, text, icon) in variants { + let mut harness = Harness::builder() + .with_size(egui::vec2(600.0, 200.0)) + .build_ui(move |ui| { + MessageBanner::set_global(ui.ctx(), text, msg_type); + MessageBanner::show_global(ui); + }); + harness.run(); + // Message text, icon, and dismiss button should all be present + assert!(harness.query_by_label(text).is_some(), "Missing text for {:?}", msg_type); + assert!(harness.query_by_label(icon).is_some(), "Missing icon for {:?}", msg_type); + assert!(harness.query_by_label("x").is_some(), "Missing dismiss button for {:?}", msg_type); + } +} + +/// Test that multiple different messages coexist (not overwritten). +#[test] +fn test_multiple_messages_coexist() { + let ctx = egui::Context::default(); + + let handle1 = MessageBanner::set_global(&ctx, "Error one", MessageType::Error); + let handle2 = MessageBanner::set_global(&ctx, "Error two", MessageType::Error); + assert!(MessageBanner::has_global(&ctx)); + assert!(handle1.elapsed().is_some()); + assert!(handle2.elapsed().is_some()); + + // Clear first — second should still exist + MessageBanner::clear_global_message(&ctx, "Error one"); + assert!(MessageBanner::has_global(&ctx)); + assert!(handle1.elapsed().is_none()); + assert!(handle2.elapsed().is_some()); + + // Clear second — nothing left + MessageBanner::clear_global_message(&ctx, "Error two"); + assert!(!MessageBanner::has_global(&ctx)); + assert!(handle2.elapsed().is_none()); +} + +/// Test that duplicate text is deduplicated (idempotent set_global). +#[test] +fn test_deduplication() { + let ctx = egui::Context::default(); + + let handle1 = MessageBanner::set_global(&ctx, "Same message", MessageType::Error); + let handle2 = MessageBanner::set_global(&ctx, "Same message", MessageType::Error); + let handle3 = MessageBanner::set_global(&ctx, "Same message", MessageType::Error); + + // All handles should reference the same banner (all alive) + assert!(handle1.elapsed().is_some()); + assert!(handle2.elapsed().is_some()); + assert!(handle3.elapsed().is_some()); + + // Only one should exist — clear_global_message removes it, then nothing left + MessageBanner::clear_global_message(&ctx, "Same message"); + assert!(!MessageBanner::has_global(&ctx)); + + // All handles should now be dead + assert!(handle1.elapsed().is_none()); + assert!(handle2.elapsed().is_none()); + assert!(handle3.elapsed().is_none()); +} + +/// Test replace_global finds and replaces an existing message. +#[test] +fn test_replace_global_finds_and_replaces() { + let ctx = egui::Context::default(); + + let original_handle = + MessageBanner::set_global(&ctx, "Generic success", MessageType::Success); + MessageBanner::set_global(&ctx, "An error", MessageType::Error); + + // Replace the generic one + let replaced_handle = MessageBanner::replace_global( + &ctx, + "Generic success", + "Specific success", + MessageType::Success, + ); + + // Both handles should point to the same banner (same key, text changed) + assert!(original_handle.elapsed().is_some()); + assert!(replaced_handle.elapsed().is_some()); + + // The old text should be gone, new text should exist + // Clearing "Generic success" should be a no-op (already replaced) + MessageBanner::clear_global_message(&ctx, "Generic success"); + // "Specific success" and "An error" should still be present + assert!(MessageBanner::has_global(&ctx)); + + // Clear the remaining two + MessageBanner::clear_global_message(&ctx, "Specific success"); + MessageBanner::clear_global_message(&ctx, "An error"); + assert!(!MessageBanner::has_global(&ctx)); +} + +/// Test replace_global with unknown old_text adds as new. +#[test] +fn test_replace_global_adds_when_not_found() { + let ctx = egui::Context::default(); + + let handle = + MessageBanner::replace_global(&ctx, "nonexistent", "New message", MessageType::Info); + assert!(MessageBanner::has_global(&ctx)); + assert!(handle.elapsed().is_some()); + + MessageBanner::clear_global_message(&ctx, "New message"); + assert!(!MessageBanner::has_global(&ctx)); + assert!(handle.elapsed().is_none()); +} + +/// Test clear_global_message removes only the specific message. +#[test] +fn test_clear_global_message_removes_specific() { + let ctx = egui::Context::default(); + + let keep_handle = MessageBanner::set_global(&ctx, "Keep this", MessageType::Error); + let remove_handle = MessageBanner::set_global(&ctx, "Remove this", MessageType::Warning); + + MessageBanner::clear_global_message(&ctx, "Remove this"); + assert!(MessageBanner::has_global(&ctx)); + assert!(keep_handle.elapsed().is_some()); + assert!(remove_handle.elapsed().is_none()); + + MessageBanner::clear_global_message(&ctx, "Keep this"); + assert!(!MessageBanner::has_global(&ctx)); + assert!(keep_handle.elapsed().is_none()); +} + +/// Test that empty string is a no-op for set_global (does not clear). +#[test] +fn test_set_empty_string_is_noop() { + let ctx = egui::Context::default(); + + MessageBanner::set_global(&ctx, "Existing", MessageType::Info); + let empty_handle = MessageBanner::set_global(&ctx, "", MessageType::Info); + // Empty string should not clear existing messages + assert!(MessageBanner::has_global(&ctx)); + // Empty handle should not reference any real banner + assert!(empty_handle.elapsed().is_none()); +} + +/// Test that per-instance set_message with empty string clears. +#[test] +fn test_instance_set_empty_string_clears() { + let mut banner = MessageBanner::new(); + banner.set_message("Some message", MessageType::Warning); + assert!(banner.has_message()); + assert_eq!(banner.current_value(), Some(BannerStatus::Visible)); + + banner.set_message("", MessageType::Warning); + assert!(!banner.has_message()); + assert_eq!(banner.current_value(), None); +} + +/// Test that a per-instance banner renders via Component::show() and returns Visible status. +#[test] +fn test_instance_banner_rendering() { + let mut banner = MessageBanner::new(); + banner.set_message("Instance error", MessageType::Error); + + let mut harness = Harness::builder() + .with_size(egui::vec2(600.0, 200.0)) + .build_ui(move |ui| { + let response = banner.show(ui); + assert_eq!(response.inner.status, Some(BannerStatus::Visible)); + assert!(!response.inner.has_changed()); + assert!(response.inner.is_valid()); + assert!(response.inner.error_message().is_none()); + }); + harness.run(); + assert!(harness.query_by_label("Instance error").is_some()); + assert!(harness.query_by_label("x").is_some()); +} + +/// Test that Component::show() returns None status when no message is set. +#[test] +fn test_instance_banner_empty_status() { + let mut banner = MessageBanner::new(); + + let mut harness = Harness::builder() + .with_size(egui::vec2(600.0, 200.0)) + .build_ui(move |ui| { + let response = banner.show(ui); + assert_eq!(response.inner.status, None); + assert!(!response.inner.has_changed()); + assert!(response.inner.is_valid()); + assert!(response.inner.error_message().is_none()); + }); + harness.run(); + // Empty banner renders nothing + assert!(harness.query_by_label("x").is_none()); +} + +/// Test that current_value() returns Visible when message is set, None otherwise. +#[test] +fn test_instance_current_value() { + let mut banner = MessageBanner::new(); + assert_eq!(banner.current_value(), None); + + banner.set_message("Some error", MessageType::Error); + assert_eq!(banner.current_value(), Some(BannerStatus::Visible)); + + banner.clear(); + assert_eq!(banner.current_value(), None); +} + +/// Test that multiple banners render and all texts appear. +#[test] +fn test_multiple_banners_render() { + let mut harness = Harness::builder() + .with_size(egui::vec2(600.0, 400.0)) + .build_ui(|ui| { + MessageBanner::set_global(ui.ctx(), "Error one", MessageType::Error); + MessageBanner::set_global(ui.ctx(), "Warning one", MessageType::Warning); + MessageBanner::set_global(ui.ctx(), "Success one", MessageType::Success); + assert!(MessageBanner::has_global(ui.ctx())); + MessageBanner::show_global(ui); + }); + harness.run(); + assert!(harness.query_by_label("Error one").is_some()); + assert!(harness.query_by_label("Warning one").is_some()); + assert!(harness.query_by_label("Success one").is_some()); + // Each banner has its own dismiss button + assert_eq!(harness.get_all_by_label("x").count(), 3); +} + +/// Test BannerHandle::clear() removes the banner. +#[test] +fn test_handle_clear() { + let ctx = egui::Context::default(); + + let handle = MessageBanner::set_global(&ctx, "To be cleared", MessageType::Info); + assert!(MessageBanner::has_global(&ctx)); + + handle.clear(); + assert!(!MessageBanner::has_global(&ctx)); +} + +/// Test BannerHandle::set_message() updates the banner text. +#[test] +fn test_handle_set_message() { + let ctx = egui::Context::default(); + + let handle = MessageBanner::set_global(&ctx, "Original text", MessageType::Info); + assert!(handle.set_message("Updated text").is_some()); + + // Old text should not match anymore + MessageBanner::clear_global_message(&ctx, "Original text"); + // Banner should still exist under the new text + assert!(MessageBanner::has_global(&ctx)); + assert!(handle.elapsed().is_some()); + + // Clearing the new text should remove it + MessageBanner::clear_global_message(&ctx, "Updated text"); + assert!(!MessageBanner::has_global(&ctx)); +} + +/// Test BannerHandle::set_message() returns None on a cleared banner. +#[test] +fn test_handle_set_message_on_cleared_banner() { + let ctx = egui::Context::default(); + + let handle = MessageBanner::set_global(&ctx, "Will be cleared", MessageType::Info); + MessageBanner::clear_global_message(&ctx, "Will be cleared"); + + assert!(handle.set_message("New text").is_none()); + // No banner should have been created + assert!(!MessageBanner::has_global(&ctx)); +} + +/// Test BannerHandle::with_auto_dismiss() returns None on a cleared banner. +#[test] +fn test_handle_with_auto_dismiss_on_cleared_banner() { + let ctx = egui::Context::default(); + + let handle = MessageBanner::set_global(&ctx, "Temporary", MessageType::Info); + MessageBanner::clear_global_message(&ctx, "Temporary"); + + assert!(handle.with_auto_dismiss(Duration::from_secs(10)).is_none()); +} + +/// Test BannerHandle::with_elapsed() returns None on a cleared banner. +#[test] +fn test_handle_with_elapsed_on_cleared_banner() { + let ctx = egui::Context::default(); + + let handle = MessageBanner::set_global(&ctx, "Gone", MessageType::Info); + MessageBanner::clear_global_message(&ctx, "Gone"); + + assert!(handle.with_elapsed().is_none()); +} + +/// Test BannerHandle::with_elapsed() returns Some on a live banner. +#[test] +fn test_handle_with_elapsed_on_live_banner() { + let ctx = egui::Context::default(); + + let handle = MessageBanner::set_global(&ctx, "Loading...", MessageType::Info); + assert!(handle.with_elapsed().is_some()); + assert!(handle.elapsed().is_some()); +} + +/// Test that handle.clear() only removes the specific banner, not others. +#[test] +fn test_handle_clear_leaves_other_banners() { + let ctx = egui::Context::default(); + + let handle1 = MessageBanner::set_global(&ctx, "Banner one", MessageType::Error); + let handle2 = MessageBanner::set_global(&ctx, "Banner two", MessageType::Warning); + + handle1.clear(); + assert!(MessageBanner::has_global(&ctx)); + assert!(handle2.elapsed().is_some()); + + handle2.clear(); + assert!(!MessageBanner::has_global(&ctx)); +} + +/// Test per-instance set_auto_dismiss builder chaining. +#[test] +fn test_instance_set_auto_dismiss() { + let mut banner = MessageBanner::new(); + banner + .set_message("Timed message", MessageType::Error) + .set_auto_dismiss(Duration::from_secs(10)); + assert!(banner.has_message()); + assert_eq!(banner.current_value(), Some(BannerStatus::Visible)); +} + +/// Test that replace_global with empty new_text clears the old message. +#[test] +fn test_replace_global_empty_new_text_clears() { + let ctx = egui::Context::default(); + + MessageBanner::set_global(&ctx, "Old message", MessageType::Info); + assert!(MessageBanner::has_global(&ctx)); + + let handle = MessageBanner::replace_global(&ctx, "Old message", "", MessageType::Info); + assert!(!MessageBanner::has_global(&ctx)); + // Handle for empty replacement should not reference a real banner + assert!(handle.elapsed().is_none()); +} + +/// Test BannerHandle::with_auto_dismiss() returns Some on a live banner. +#[test] +fn test_handle_with_auto_dismiss_on_live_banner() { + let ctx = egui::Context::default(); + + let handle = MessageBanner::set_global(&ctx, "Dismissable", MessageType::Error); + assert!( + handle + .with_auto_dismiss(Duration::from_secs(30)) + .is_some() + ); + assert!(handle.elapsed().is_some()); +} From 0dbed4a34552a5d0fa4ff8f6fd0ab282ac214f63 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:51:36 +0100 Subject: [PATCH 04/19] chore: docs --- .../architecture.md | 889 +++--------------- .../message-banner-ux-spec.md | 266 ++---- 2 files changed, 230 insertions(+), 925 deletions(-) diff --git a/docs/ai-design/2026-02-17-unified-messages/architecture.md b/docs/ai-design/2026-02-17-unified-messages/architecture.md index b51d5af16..9b031a8b1 100644 --- a/docs/ai-design/2026-02-17-unified-messages/architecture.md +++ b/docs/ai-design/2026-02-17-unified-messages/architecture.md @@ -1,834 +1,247 @@ # MessageBanner Component -- Technical Architecture -## 0. Current State Analysis - -### Overview - -The codebase has **~50 screens** (structs implementing `ScreenLike`) with no unified message rendering component. There are **4 distinct rendering patterns**, **8+ different error colors**, and **two competing `MessageType` enums**. Each screen independently manages its own message fields and rendering. - -### Rendering Style Taxonomy - -| Pattern | Description | Used By | -|---------|-------------|---------| -| **A - Framed Banner** | `Frame` with `fill(color.gamma_multiply(0.1))`, border stroke, RichText label, "Dismiss" button | ~8 screens | -| **B - Timed Badge** | `(String, MessageType, DateTime)` tuple, rendered with countdown timer (auto-dismiss ~10s) | ~8 screens | -| **C - Status Enum** | Error stored in a status enum variant (e.g., `TransferCreditsStatus::ErrorMessage`), rendered inline via `colored_label` | ~8 screens | -| **D - Bare colored_label** | Simple `ui.colored_label(Color32::X, message)` inline, no frame, no dismiss | ~20+ screens | - -### Error Color Inconsistency - -| Color Value | Usage Count | Screens | -|---|---|---| -| `Color32::from_rgb(255, 100, 100)` | ~8 screens | AddNewIdentityScreen, TopUpIdentityScreen, WalletsBalancesScreen, WalletSendScreen, PlatformInfoScreen, AddressBalanceScreen, KeyInfoScreen, ImportMnemonicScreen | -| `Color32::DARK_RED` | ~10 screens | WithdrawalScreen, TransferScreen, RegisterDpnsNameScreen, IdentitiesScreen, DPNSScreen, ContactsList, ContactRequests, AddContactScreen, SendPaymentScreen, SetTokenPriceScreen | -| `Color32::RED` | ~5 screens | FreezeTokensScreen, UnfreezeTokensScreen, PauseTokensScreen, ResumeTokensScreen, ClaimTokensScreen | -| `DashColors::error_color(dark_mode)` | ~7 screens | MintTokensScreen, BurnTokensScreen, DestroyFrozenFundsScreen, ProfileSearchScreen, ContactProfileViewerScreen, QRCodeGeneratorScreen, QRScannerScreen | -| `DashColors::ERROR` (`from_rgb(235,87,87)`) | ~1 screen | ContactInfoEditorScreen | -| `Color32::from_rgb(220, 80, 80)` | ~1 screen | WalletUnlockPopup | - -### Per-Screen Catalog - -#### Identities - -| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | -|---|---|---|---|---|---|---| -| `IdentitiesScreen` | `identities/identities_screen.rs` | `backend_message: Option<(String, MessageType, DateTime)>` | Pattern B (timed, 10s auto-dismiss with countdown) | Custom: sets `backend_message` | Custom: `RefreshedIdentity` → Success | Shows spinner during refresh; Error=`DARK_RED`, Success=`DARK_GREEN` | -| `AddNewIdentityScreen` | `identities/add_new_identity_screen/mod.rs` | `error_message: Option` | Pattern A (framed banner + Dismiss) | Custom: sets `error_message` | Default | Prefix: `"Error registering identity: {}"`. Color: `from_rgb(255,100,100)` | -| `AddExistingIdentityScreen` | `identities/add_existing_identity_screen.rs` | `error_message`, `backend_message`, `success_message` | Pattern A for errors | Custom: routes to 3 fields | Custom: `LoadedIdentity`, `Message` | **3 separate message fields**; `backend_message` = progress indicator | -| `TopUpIdentityScreen` | `identities/top_up_identity_screen/mod.rs` | `error_message: Option` | Pattern A (framed banner + Dismiss) | Custom: sets `error_message`; resets step state on Error | Default | Also resets `WalletFundedScreenStep` on error | -| `WithdrawalScreen` | `identities/withdraw_screen.rs` | `error_message`, `withdraw_from_identity_status` enum | Pattern C (status enum) | Custom: sets status enum | Custom: `WithdrewFromIdentity` → Complete | Status enum is primary error carrier | -| `TransferScreen` | `identities/transfer_screen.rs` | `error_message`, `transfer_credits_status` enum | Pattern C (status enum) | Custom: sets **both** enum AND `error_message` | Custom: `TransferredCredits` → Complete | **Redundant dual-field storage** | -| `RegisterDpnsNameScreen` | `identities/register_dpns_name_screen.rs` | `error_message: Option` | Pattern D (bare colored_label) | Custom: sets `error_message` | Default | Color: `DARK_RED` | - -#### Identity Keys - -| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | -|---|---|---|---|---|---|---| -| `KeysScreen` | `identities/keys/keys_screen.rs` | None | None | Default no-op | Default | Minimal screen, no messages | -| `KeyInfoScreen` | `identities/keys/key_info_screen.rs` | `error_message`, `sign_error_message` | Pattern A-like for `error_message`; Pattern D for `sign_error_message` | Default no-op | Default | **2 error fields** for different operations; no `display_message` override | -| `AddKeyScreen` | `identities/keys/add_key_screen.rs` | `error_message`, `add_key_status` enum | Pattern C (status enum) | Custom: sets `add_key_status` | Custom: `AddedKeyToIdentity` → Complete | | - -#### DPNS - -| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | -|---|---|---|---|---|---|---| -| `DPNSScreen` | `dpns/dpns_contested_names_screen.rs` | `message: Option<(String, MessageType, DateTime)>`, `bulk_schedule_message` | Pattern B (timed) + bulk block | Custom: sets `message` | Custom: extensive vote handling | **2 message slots**; bulk message uses emoji icons (❌/🎉) | - -#### Wallets - -| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | -|---|---|---|---|---|---|---| -| `WalletsBalancesScreen` | `wallets/wallets_screen/mod.rs` | `message: Option<(String, MessageType, DateTime)>`, `sk_error_message` | Pattern A (framed banner + Dismiss) | Custom: routes errors to fund dialog if processing | Custom: wallet ops | **Dialog interception**: fund-platform dialog captures errors | -| `AddNewWalletScreen` | `wallets/add_new_wallet_screen.rs` | `error: Option` | **Modal Window** (`egui::Window::new("Error")`) | Default no-op | Default | Only screen using modal error popup | -| `ImportMnemonicScreen` | `wallets/import_mnemonic_screen.rs` | `error: Option` | Pattern D (bare colored_label) | Default no-op | Default | No `display_message` override | -| `WalletSendScreen` | `wallets/send_screen.rs` | `send_status: SendStatus`, `error_message` | Pattern A (framed banner) + full success screen | Custom: sets `send_status` enum | Custom: `WalletPayment`, etc. | Status enum drives display; success shows full-page 🎉 view | -| `SingleKeyWalletSendScreen` | `wallets/single_key_send_screen.rs` | `message: Option<(String, MessageType, DateTime)>`, `error_message` | Pattern B (timed banner) | Custom: sets `message` | Custom: `WalletPayment` | 2 fields | -| `CreateAssetLockScreen` | `wallets/create_asset_lock_screen.rs` | `message: Option<(String, MessageType, DateTime)>`, `error_message` | Pattern B (timed banner) | Custom: sets `message` | Custom: asset lock types | Helper methods `set_error_message()`/`error_message()` | -| `AssetLockDetailScreen` | `wallets/asset_lock_detail_screen.rs` | `message: Option<(String, MessageType, DateTime)>`, `error_message` | Pattern B (timed banner) | Custom: sets `message` | Custom: asset lock retrieval | Helper methods | - -#### Contracts / Documents - -| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | -|---|---|---|---|---|---|---| -| `DocumentQueryScreen` | `contracts_documents/contracts_documents_screen.rs` | `error_message: Option<(String, MessageType, DateTime)>` | Pattern D (colored_label + timestamp expiry) | Custom: **only handles** `"Error fetching documents"` | Custom: Documents/PageDocuments | **Filters messages by text content** | -| `AddContractsScreen` | `contracts_documents/add_contracts_screen.rs` | `add_contracts_status` enum | Pattern C (status enum) | Custom: sets status enum | Custom: `FetchedContracts` | | -| `RegisterDataContractScreen` | `contracts_documents/register_contract_screen.rs` | `error_message`, `broadcast_status` enum | Pattern C + Pattern A | Custom: routes by text pattern | Custom: `RegisteredContract` | **Text-pattern branch** for proof error special case | -| `UpdateDataContractScreen` | `contracts_documents/update_contract_screen.rs` | `error_message`, `broadcast_status` enum | Pattern C + inline | Custom: same as Register | Custom: `UpdatedContract` | Same pattern as Register | -| `DocumentActionScreen` | `contracts_documents/document_action_screen.rs` | `backend_message: Option` | Not rendered via banner | Custom: sets `backend_message`; **ignores message_type** | Custom: Broadcasted/Deleted | `_message_type` — type parameter unused | -| `GroupActionsScreen` | `contracts_documents/group_actions_screen.rs` | `fetch_group_actions_status` enum | Pattern C (status enum) | Custom: sets status enum | Custom: `ActiveGroupActions` | | - -#### Tokens - -| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | -|---|---|---|---|---|---|---| -| `TokensScreen` | `tokens/tokens_screen/mod.rs` | `backend_message: Option<(String, MessageType, DateTime)>`, `token_creator_error_message` | Pattern B (timed banner) | Custom: smart routing (creator subpanel vs main) | Custom: extensive | **2 fields**; smart dispatch based on state | -| `MintTokensScreen` | `tokens/mint_tokens_screen.rs` | `error_message: Option` | Pattern D (colored_label) | Custom: Error → `error_message` | Default | Color: `DashColors::error_color(dark)` | -| `BurnTokensScreen` | `tokens/burn_tokens_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `DashColors::error_color(dark)` | -| `TransferTokensScreen` | `tokens/transfer_tokens_screen.rs` | `transfer_tokens_status` enum | Pattern C (status enum) | Custom: sets status enum | Custom: `TransferredTokens` | No `error_message` field | -| `FreezeTokensScreen` | `tokens/freeze_tokens_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | **Color: `Color32::RED`** (not DARK_RED) | -| `UnfreezeTokensScreen` | `tokens/unfreeze_tokens_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `Color32::RED` | -| `PauseTokensScreen` | `tokens/pause_tokens_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `Color32::RED` | -| `ResumeTokensScreen` | `tokens/resume_tokens_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `Color32::RED` | -| `DestroyFrozenFundsScreen` | `tokens/destroy_frozen_funds_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `DashColors::error_color(dark)` | -| `ClaimTokensScreen` | `tokens/claim_tokens_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `Color32::RED` | -| `ViewTokenClaimsScreen` | `tokens/view_token_claims_screen.rs` | `message: Option<(String, MessageType, DateTime)>` | Pattern D | Custom: all types → `message` | Custom: calls display_message if empty | | -| `AddTokenByIdScreen` | `tokens/add_token_by_id_screen.rs` | `error_message: Option` | Pattern D | Custom: Success = refresh; Error = `error_message` | (inline) | `display_message` handles Success as task result | -| `PurchaseTokenScreen` | `tokens/direct_token_purchase_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | | -| `SetTokenPriceScreen` | `tokens/set_token_price_screen.rs` | `error_message: Option` | Pattern D | Custom: Error → `error_message` | Default | Color: `DARK_RED`; also has amber validation labels | -| `UpdateTokenConfigScreen` | `tokens/update_token_config.rs` | `backend_message: Option<(String, MessageType, DateTime)>`, `error_message` (unused) | Pattern B (timed banner) | Custom: Error/Info → `backend_message` | Custom: `UpdatedTokenConfig` | **`error_message` field is explicitly commented `// unused`** | - -#### Tools - -| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | -|---|---|---|---|---|---|---| -| `PlatformInfoScreen` | `tools/platform_info_screen.rs` | `error_message: Option` | Pattern A (framed banner + Dismiss) | Custom: Error → `error_message` | Custom: PlatformInfo results | **Error blocks result display** (early return) | -| `GroveSTARKScreen` | `tools/grovestark_screen.rs` | `gen_error_message`, `verify_error_message` | Pattern D (with Dismiss in framed box) | Custom: routes to gen/verify based on mode | Custom: GeneratedProof, VerifiedProof | **2 mode-specific error fields** | -| `MasternodeListDiffScreen` | `tools/masternode_list_diff_screen.rs` | `ui_state.message: Option<(String, MessageType)>`, `ui_state.error` | Pattern A (framed banner) | Custom: Error → `ui_state.error`; Info → **silently discarded** | Custom: CoreItem, extensive | **Info messages explicitly dropped** | -| `ProofLogScreen` | `tools/proof_log_screen.rs` | None | None | Explicit no-op | Default | **Fully ignores all messages** | -| `ProofVisualizerScreen` | `tools/proof_visualizer_screen.rs` | None | None | Explicit no-op | Default | **Fully ignores all messages** | -| `TransitionVisualizerScreen` | `tools/transition_visualizer_screen.rs` | `broadcast_status` enum, `contract_fetch_message: Option<(String, Instant)>` | Pattern C (status enum) | Custom: routes by type | Custom: FetchedContract | `contract_fetch_message` is time-based | -| `AddressBalanceScreen` | `tools/address_balance_screen.rs` | `error_message: Option` | Pattern A (framed banner + Dismiss) | Custom: Error → `error_message` | Custom: AddressBalance | Color: `from_rgb(255,100,100)` | - -#### DashPay - -| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | -|---|---|---|---|---|---|---| -| `DashPayScreen` | `dashpay/dashpay_screen.rs` | None (delegates) | Delegates to active sub-screen | Custom: dispatches to sub-screens | Default | Pure delegator | -| `ContactsList` | `dashpay/contacts_list.rs` | `message: Option<(String, MessageType)>` | Pattern D | Custom: sets `message` | Custom | Color: `DARK_RED`/`DARK_GREEN`/`LIGHT_BLUE` | -| `ContactRequests` | `dashpay/contact_requests.rs` | `message: Option<(String, MessageType)>` | Pattern D (bold for errors) | Custom: sets `message` | Custom | | -| `ContactDetailsScreen` | `dashpay/contact_details.rs` | `message: Option<(String, MessageType)>` | Pattern D | **Public + trait impl** (trait delegates to pub fn) | Custom | | -| `ContactInfoEditorScreen` | `dashpay/contact_info_editor.rs` | `message: Option<(String, MessageType)>` | Pattern D | **Public + trait impl** | Custom | Uses `DashColors::SUCCESS/ERROR/INFO` constants | -| `ProfileSearchScreen` | `dashpay/profile_search.rs` | `message: Option<(String, MessageType)>` | Pattern D | **Public + trait impl** | Custom | Color: `DashColors::error_color(dark)` | -| `ContactProfileViewerScreen` | `dashpay/contact_profile_viewer.rs` | `message: Option<(String, MessageType)>` | Pattern D | **Public + trait impl** | Custom | | -| `AddContactScreen` | `dashpay/add_contact_screen.rs` | `message: Option<(String, MessageType)>` | Pattern D | Custom: wraps `AddContactError` | Custom | | -| `QRCodeGeneratorScreen` | `dashpay/qr_code_generator.rs` | `message: Option<(String, MessageType)>` | Pattern D | **Public + trait impl** | Default | | -| `QRScannerScreen` | `dashpay/qr_scanner.rs` | `message: Option<(String, MessageType)>` | Pattern D | **Public + trait impl** | Custom | | -| `SendPaymentScreen` | `dashpay/send_payment.rs` | `message: Option<(String, MessageType)>` | Pattern D | Custom: sets `sending=false` + `message` | Custom: `DashPayPaymentSent` | | - -#### Network / Other - -| Screen | File | Message Fields | Rendering | `display_message` | `display_task_result` | Quirks | -|---|---|---|---|---|---|---| -| `NetworkChooserScreen` | `network_chooser_screen.rs` | `custom_dash_qt_error_message`, `spv_clear_message`, `db_clear_message` | Inline specialized fields | Default no-op | Default | **3 specialized local message fields**; no standard message pipeline | - -### Key Issues Summary - -1. **No unified rendering component** — 4 distinct visual patterns with no shared helper -2. **8+ different error colors** — ranges from `Color32::RED` to `Color32::DARK_RED` to `from_rgb(255,100,100)` to `DashColors::error_color(dark)` -3. **Screens silently ignoring messages** — `KeysScreen`, `ProofLogScreen`, `ProofVisualizerScreen` (no-op), `MasternodeListDiffScreen` (drops Info), `DocumentActionScreen` (ignores type) -4. **Redundant dual-field storage** — `TransferScreen` stores errors in both status enum and `error_message` -5. **Dead code** — `UpdateTokenConfigScreen.error_message` explicitly marked unused; `theme.rs::MessageType` is dead code -6. **6 screens with public+trait dual display_message** — DashPay sub-screens have both a `pub fn` and `ScreenLike` impl -7. **Inconsistent auto-dismiss** — 8 screens use `DateTime` timestamps for timed messages; the rest persist until user action -8. **Success messages often silently lost** — Token action screens (Mint, Burn, Freeze, etc.) use default `display_task_result` which calls `display_message("Success", Success)`, but their `display_message` only handles Error +## 1. Overview ---- - -## 1. MessageType Enum Unification - -### Current State - -Two competing `MessageType` enums exist: - -1. **`src/ui/mod.rs:830`** -- Active, 3 variants (Success, Info, Error), used by `ScreenLike` trait -2. **`src/ui/theme.rs:638`** -- Dead code (`#[allow(dead_code)]`), 4 variants (Success, Error, Warning, Info), has `color()` and `background_color()` methods +`MessageBanner` is a unified message display component that replaces the ~50 screens' ad-hoc `error_message`, `backend_message`, and `message` fields with a single consistent banner system. It operates in two modes: -### Decision +- **Global**: Multiple banners stored in egui context data, rendered centrally by `island_central_panel()`. Used for backend task results and screen-level messages. +- **Per-instance**: A `MessageBanner` struct owned by a screen, implementing the `Component` trait. Available for screen-local use cases. -**Extend** the active enum in `mod.rs` by adding a `Warning` variant. **Consolidate** the color methods from the dead-code `theme.rs::MessageType` into `DashColors` as dark-mode-aware methods (the dead enum only had light-mode colors). Then **delete** the dead-code enum in `theme.rs` (lines 636-664). +Both modes share the same rendering function (`render_banner()`), ensuring visual consistency. -### Unified Enum (in `src/ui/mod.rs`) +**File**: `src/ui/components/message_banner.rs` -```rust -#[derive(Debug, PartialEq, Clone, Copy)] -pub enum MessageType { - Success, - Info, - Warning, - Error, -} -``` +--- -This is the only change to the existing `MessageType`. The variant order follows severity (lowest to highest). Adding `Warning` is backward-compatible -- existing match arms that use `Success`, `Info`, `Error` will get a compiler error only in exhaustive matches, which is desirable to ensure all sites handle the new variant. +## 2. Data Structures -### New DashColors Methods (in `src/ui/theme.rs`) +### BannerState (private) -Add `info_color(dark_mode)` and `message_background_color(message_type, dark_mode)` to `DashColors`, consolidating all message-type-aware colors in one place: +Internal state for a single banner message: ```rust -impl DashColors { - /// Info severity color -- complements existing error_color/success_color/warning_color. - pub fn info_color(dark_mode: bool) -> Color32 { - if dark_mode { - Color32::from_rgb(100, 180, 255) // Lighter blue for dark mode - } else { - Self::DEEP_BLUE // Dark blue for light mode - } - } - - /// Returns the tinted background color for a message severity level. - /// Uses low alpha (8% light, 12% dark) for subtle tinting. - pub fn message_background_color(message_type: MessageType, dark_mode: bool) -> Color32 { - let alpha = if dark_mode { 30 } else { 20 }; - match message_type { - MessageType::Error => { - let c = if dark_mode { (255, 100, 100) } else { (235, 87, 87) }; - Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha) - } - MessageType::Warning => { - let c = if dark_mode { (255, 200, 100) } else { (241, 196, 15) }; - Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha) - } - MessageType::Success => { - let c = if dark_mode { (80, 200, 120) } else { (39, 174, 96) }; - Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha) - } - MessageType::Info => { - let c = if dark_mode { (100, 180, 255) } else { (52, 152, 219) }; - Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha) - } - } - } - - /// Returns the foreground (text/border) color for a message severity level. - /// Delegates to existing per-severity color methods. - pub fn message_color(message_type: MessageType, dark_mode: bool) -> Color32 { - match message_type { - MessageType::Error => Self::error_color(dark_mode), - MessageType::Warning => Self::warning_color(dark_mode), - MessageType::Success => Self::success_color(dark_mode), - MessageType::Info => Self::info_color(dark_mode), - } - } +struct BannerState { + key: u64, // Unique ID from AtomicU64 counter + text: String, // Display text + message_type: MessageType, // Error | Warning | Success | Info + created_at: Instant, // Monotonic timestamp for timing + auto_dismiss_after: Option, // None = persistent, Some = countdown + show_elapsed: bool, // Show elapsed time instead of countdown } ``` -This approach: -- Keeps all color definitions in `DashColors` (single source of truth for the design system) -- Reuses existing `error_color`/`success_color`/`warning_color` methods for foreground colors -- Fills the gap: `info_color(dark_mode)` was missing -- Replaces the dead `theme.rs::MessageType` color methods with dark-mode-aware equivalents - ---- - -## 2. MessageBanner Component - -### Alignment with Component Design Pattern - -`MessageBanner` follows the conventions established in `doc/COMPONENT_DESIGN_PATTERN.md`: - -| Convention | How MessageBanner Applies It | -|---|---| -| **Private fields only** | `state: Option` is private | -| **`new()` constructor** | `MessageBanner::new()` creates an empty banner | -| **Builder methods** | `with_auto_dismiss_duration()` for configurable timeout | -| **`show()` method** | `show(&mut self, ui: &mut Ui)` renders the banner | -| **Self-contained** | Handles rendering, dismiss, auto-dismiss, and color resolution internally | -| **Colors via design system** | Uses `DashColors::message_color()`, `DashColors::message_background_color()`, and `DashColors::text_secondary()` — no hardcoded colors | - -**Why MessageBanner does NOT implement the `Component` trait:** - -The `Component` trait (in `component_trait.rs`) is designed for **input** components that produce domain values — `AmountInput` produces `Amount`, `ConfirmationDialog` produces `ConfirmationStatus`. Each has `DomainType`, `ComponentResponse` with `has_changed()`/`changed_value()`/`update()`, and `current_value()`. +### MessageType -`MessageBanner` is a **display** component that **consumes** data (receives messages) rather than **producing** domain values. It has no meaningful `DomainType` to bind, no user-produced value to return, and no `update()` target. Forcing the `Component` trait would require artificial type parameters (`DomainType = ()`) that add complexity without value. +The unified enum in `src/ui/mod.rs` with four variants: `Error`, `Warning`, `Success`, `Info`. Colors are resolved via `DashColors::message_color(type, dark_mode)` and `DashColors::message_background_color(type, dark_mode)`. -This is consistent with how other display-only widgets in the codebase work — e.g., `StyledButton`, `GradientHeading`, `InfoPopup` all have `show()` without implementing `Component`. +### BannerHandle -### File Location - -`src/ui/components/message_banner.rs` - -Register in `src/ui/components/mod.rs`: -```rust -pub mod message_banner; -pub use message_banner::MessageBanner; -``` - -### Struct Definition +A `'static` handle returned by `set_global` and `replace_global`. Identifies a banner by its internal `u64` key, allowing text updates and configuration changes after creation: ```rust -use crate::ui::MessageType; -use crate::ui::theme::DashColors; -use std::time::{Duration, Instant}; - -const DEFAULT_AUTO_DISMISS_DURATION: Duration = Duration::from_secs(5); - -/// A self-contained banner widget for displaying screen-level messages. -/// -/// Each screen owns one instance. Call `show()` every frame inside the -/// screen's `ui()` method, before the `ScrollArea`. The banner renders -/// nothing when no message is set. -/// -/// Follows the component conventions from `doc/COMPONENT_DESIGN_PATTERN.md`: -/// private fields, `new()` constructor, builder methods, `show()` rendering. -pub struct MessageBanner { - /// Current message state: text, severity, and the instant it was set. - /// `None` means no message is displayed. - state: Option, - /// Duration before Success/Info messages auto-dismiss. - auto_dismiss_duration: Duration, -} - -struct MessageState { - text: String, - message_type: MessageType, - created_at: Instant, +pub struct BannerHandle { + ctx: egui::Context, // egui Context is Arc>, cheap to clone + key: u64, // Unique key assigned at creation } ``` -The struct is intentionally minimal. `state` is `Option` so the banner occupies zero layout space when empty. `Instant` is used for auto-dismiss timing because it is monotonic and does not depend on wall clock. +All query/mutation methods return `Option` to handle the case where the banner has been dismissed or expired: -### Public API +| Method | Signature | Purpose | +|--------|-----------|---------| +| `elapsed()` | `-> Option` | Time since creation (looked up from context data, not stored on handle) | +| `set_message()` | `(&self, text: &str) -> Option<&Self>` | Update display text | +| `with_auto_dismiss()` | `(&self, Duration) -> Option<&Self>` | Set/override countdown duration; resets timer to now | +| `with_elapsed()` | `-> Option<&Self>` | Enable elapsed-time display mode (disables auto-dismiss) | +| `clear()` | `(self)` | Remove banner immediately (consumes handle) | -```rust -impl MessageBanner { - /// Creates an empty banner (no message displayed). - pub fn new() -> Self { - Self { - state: None, - auto_dismiss_duration: DEFAULT_AUTO_DISMISS_DURATION, - } - } - - /// Builder: set custom auto-dismiss duration for Success/Info messages. - pub fn with_auto_dismiss_duration(mut self, duration: Duration) -> Self { - self.auto_dismiss_duration = duration; - self - } - - /// Sets or replaces the current message. Resets the auto-dismiss timer. - /// An empty string is treated as a clear operation. - pub fn set_message(&mut self, text: &str, message_type: MessageType) { - if text.is_empty() { - self.state = None; - return; - } - self.state = Some(MessageState { - text: text.to_string(), - message_type, - created_at: Instant::now(), - }); - } - - /// Clears the current message immediately. - pub fn clear(&mut self) { - self.state = None; - } - - /// Returns whether a message is currently displayed. - pub fn has_message(&self) -> bool { - self.state.is_some() - } - - /// Renders the banner into the given `Ui`. - /// Call this every frame before the ScrollArea. - pub fn show(&mut self, ui: &mut egui::Ui) { - // Implementation below - } -} +Methods that modify the banner (`set_message`, `with_auto_dismiss`, `with_elapsed`) return early without writing back to context data when the banner no longer exists. -impl Default for MessageBanner { - fn default() -> Self { - Self::new() - } -} -``` +--- -### Rendering Implementation (`show`) +## 3. Global API -All colors are resolved through `DashColors` methods — no hardcoded `Color32` values in the component. +### Multi-Message Support -```rust -pub fn show(&mut self, ui: &mut egui::Ui) { - // 1. Check if there is a message to display - let Some(state) = &self.state else { return }; - - // 2. Auto-dismiss check for Success and Info - let auto_dismiss = matches!(state.message_type, MessageType::Success | MessageType::Info); - if auto_dismiss && state.created_at.elapsed() >= self.auto_dismiss_duration { - self.state = None; - return; - } - - // 3. Resolve colors via DashColors (single source of truth) - let dark_mode = ui.ctx().style().visuals.dark_mode; - let fg_color = DashColors::message_color(state.message_type, dark_mode); - let bg_color = DashColors::message_background_color(state.message_type, dark_mode); - let secondary_color = DashColors::text_secondary(dark_mode); - - // 4. Compute remaining seconds for countdown (only for auto-dismiss types) - let remaining_secs = if auto_dismiss { - let elapsed = state.created_at.elapsed(); - self.auto_dismiss_duration - .checked_sub(elapsed) - .map(|d| d.as_secs() + 1) // +1 so we show "1s" until it actually expires - } else { - None - }; - - // 5. Render using DashColors and theme constants - let icon = icon_for_type(state.message_type); - let text = state.text.clone(); // clone to release borrow on self - let mut dismissed = false; - - egui::Frame::new() - .fill(bg_color) - .inner_margin(egui::Margin::symmetric( - Spacing::SM_I8 + Spacing::XXS as i8, // 10px - Spacing::SM_I8, // 8px - )) - .corner_radius(Shape::RADIUS_SM as f32) // 6px - .stroke(egui::Stroke::new(Shape::BORDER_WIDTH, fg_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - // Icon - ui.label(egui::RichText::new(icon).color(fg_color).strong()); - ui.add_space(Spacing::XS); // 4px - - // Message text (wrapping allowed via Label) - ui.label(egui::RichText::new(&text).color(fg_color)); - - // Flexible space - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // Dismiss button (rightmost) - if ui.small_button("x").clicked() { - dismissed = true; - } - - // Countdown (before dismiss button in RTL layout) - if let Some(secs) = remaining_secs { - ui.label( - egui::RichText::new(format!("({}s)", secs)) - .font(Typography::body_small()) - .color(secondary_color), - ); - } - }); - }); - }); - ui.add_space(Spacing::SM as f32); // 8px below banner - - if dismissed { - self.state = None; - } -} -``` +The global store is a `Vec` in egui's temporary context data, keyed by `egui::Id::new("__global_message_banner")`. Multiple messages can coexist (capped at `MAX_BANNERS = 5`, oldest evicted first). -### Color Resolution — All Through DashColors +| Method | Signature | Behavior | +|--------|-----------|----------| +| `set_global` | `(ctx, text, type) -> BannerHandle` | Add message (dedup by text). Returns handle. | +| `replace_global` | `(ctx, old_text, new_text, type) -> BannerHandle` | Find by `old_text`, replace (resets timer, auto-dismiss, and `show_elapsed`). Falls back to `set_global` if not found. | +| `clear_global_message` | `(ctx, text)` | Remove specific message by text match. | +| `has_global` | `(ctx) -> bool` | Any messages present? | +| `show_global` | `(ui)` | Render all banners, handle auto-dismiss/elapsed, remove expired. | -The banner uses **zero hardcoded colors**. All color resolution goes through `DashColors`: +**Deduplication**: `set_global` with the same text returns a handle to the existing banner without creating a duplicate. Key-based lookup is used after handle creation. -| Purpose | Method | Source | -|---|---|---| -| Text & border color | `DashColors::message_color(type, dark_mode)` | Delegates to `error_color()` / `success_color()` / `warning_color()` / `info_color()` | -| Background tint | `DashColors::message_background_color(type, dark_mode)` | Semantic backgrounds at 8% (light) / 12% (dark) alpha | -| Countdown text | `DashColors::text_secondary(dark_mode)` | Standard secondary text color | -| Spacing values | `Spacing::XS`, `Spacing::SM`, etc. | Theme constants | -| Corner radius | `Shape::RADIUS_SM` (6px) | Theme constants | -| Border width | `Shape::BORDER_WIDTH` (1px) | Theme constants | +**Empty text**: `set_global("")` is a no-op (returns a dead handle). `replace_global(old, "", type)` clears the old message. -This ensures that if the design system colors are updated globally, the banner automatically reflects the changes. +### Auto-Dismiss Defaults -### Icon Selection +| MessageType | Default | +|-------------|---------| +| Success | 5 seconds countdown | +| Info | 5 seconds countdown | +| Error | Persistent (manual dismiss only) | +| Warning | Persistent (manual dismiss only) | -```rust -fn icon_for_type(message_type: MessageType) -> &'static str { - match message_type { - MessageType::Error => "\u{26A0}", // ⚠ warning sign (visually distinct via color) - MessageType::Warning => "\u{26A0}", // ⚠ same glyph, differentiated by color - MessageType::Success => "\u{2713}", // ✓ check mark - MessageType::Info => "\u{2139}", // ℹ info - } -} -``` +### Elapsed-Time Mode -Note: If the egui font does not render these Unicode characters, fall back to ASCII: `"!"`, `"!"`, `"v"`, `"i"`. The implementer should verify glyph availability at development time. +Calling `handle.with_elapsed()` on a banner switches it to elapsed-time display: shows `(Ns)` counting up from 0, and disables auto-dismiss. Used for long-running operations like identity refresh. --- -## 3. ScreenLike Trait Changes +## 4. Per-Instance API -### Current Signature (unchanged) +`MessageBanner` as a struct implements the `Component` trait: ```rust -pub trait ScreenLike { - fn refresh(&mut self) {} - fn refresh_on_arrival(&mut self) { self.refresh() } - fn ui(&mut self, ctx: &Context) -> AppAction; - fn display_message(&mut self, _message: &str, _message_type: MessageType) {} - fn display_task_result(&mut self, _backend_task_success_result: BackendTaskSuccessResult) { - self.display_message("Success", MessageType::Success) - } - fn pop_on_success(&mut self) {} +pub struct MessageBanner { + state: Option, } ``` -**No signature change is needed.** The `display_message` method signature already accepts `&str` and `MessageType`. Adding `Warning` to the `MessageType` enum is the only change, and it flows through the existing signature. +| Method | Purpose | +|--------|---------| +| `new()` | Empty banner | +| `set_message(text, type)` | Set/replace message (empty text clears) | +| `set_auto_dismiss(duration)` | Override auto-dismiss duration | +| `clear()` | Remove message | +| `has_message()` | Check if displaying | -### Default Implementation Consideration +`MessageBanner` also implements `Default` (equivalent to `new()`). -It would be tempting to add a default `display_message` implementation that delegates to a `banner` field. However, `ScreenLike` is a trait and has no access to struct fields, so no default implementation is possible without an additional accessor method. - -**Option considered and rejected:** Adding a `fn banner(&mut self) -> &mut MessageBanner` method to ScreenLike. This would save boilerplate but forces every screen to have a field named in a specific way, and changes the trait contract for all 45+ implementors simultaneously. The cost of the two-line `display_message` implementation per screen is low. - -**Recommendation:** Each screen implements `display_message` as a two-line method: - -```rust -fn display_message(&mut self, message: &str, message_type: MessageType) { - self.banner.set_message(message, message_type); -} -``` - -Some screens perform additional logic in `display_message` (e.g., `TopUpIdentityScreen` resets its step state on error). Those screens keep their custom logic and add the `banner.set_message()` call. +The `Component` trait implementation: +- `DomainType = BannerStatus` (enum: `Visible`, `Dismissed`, `TimedOut`) +- `Response = MessageBannerResponse` — struct with `pub status: Option` and private `changed: bool` +- `show(ui)` renders the banner and returns `InnerResponse` +- `current_value()` returns `Some(Visible)` when a message is set --- -## 4. Migration Plan - -### Phase 1: Create Component + Unify MessageType + Extend DashColors - -**Scope:** 3 files changed, 1 file created - -1. **Update `src/ui/mod.rs`** -- Add `Warning` variant to `MessageType` enum (line 830). -2. **Update `src/ui/theme.rs`** -- Add `info_color(dark_mode)`, `message_color(message_type, dark_mode)`, and `message_background_color(message_type, dark_mode)` to `DashColors`. Delete the dead-code `MessageType` enum and its `impl` block (lines 636-664). -3. **Create `src/ui/components/message_banner.rs`** -- Full `MessageBanner` implementation as described in Section 2, using only `DashColors` for colors and theme constants for spacing/shape. -4. **Update `src/ui/components/mod.rs`** -- Add `pub mod message_banner; pub use message_banner::MessageBanner;` -5. **Fix compile errors** -- Any exhaustive `match` on `MessageType` will need a `Warning` arm. These are likely limited to `app.rs` (the `TaskResult` routing) and any screen that matches on message type in `display_message`. Add `MessageType::Warning => { /* same as Error for now */ }` to each. - -**Validation:** `cargo build` succeeds. No behavioral change yet. - -### Phase 2: Migrate Screens Incrementally +## 5. Rendering -Each screen migration follows the same mechanical pattern. Screens can be migrated one at a time in separate commits, enabling incremental review. +Both global and per-instance paths call `render_banner()`: -**Per-screen changes:** - -1. Add `use crate::ui::components::MessageBanner;` (if not already via glob import) -2. Replace `error_message: Option` (and similar fields) with `banner: MessageBanner` -3. In `new()` / constructor: replace `error_message: None` with `banner: MessageBanner::new()` -4. In `ui()`: replace the inline `Frame` error rendering block with `self.banner.show(ui);` -5. In `display_message()`: replace `self.error_message = Some(...)` with `self.banner.set_message(message, message_type);` -6. Remove any manual dismiss logic (the banner handles it) -7. If the screen sets `self.error_message = Some(...)` inline during `ui()`, replace with `self.banner.set_message(...)` -8. If the screen clears the error (e.g., `self.error_message = None`), replace with `self.banner.clear()` - -**Estimated effort per screen:** ~5-15 lines changed per screen file. Mechanical, low risk. - -**Migration order suggestion:** -1. Start with a simple screen (e.g., `transfer_screen.rs`) as a proof-of-concept -2. Then migrate screens with the exact duplicate pattern (the `Frame` + dismiss button block identical to `top_up_identity_screen`) -3. Then migrate screens with custom `display_message` logic -4. Finally migrate screens that use status enums with `ErrorMessage(String)` variants +``` +render_banner(ui, text, message_type, annotation: Option<&str>) -> bool (dismissed?) +``` -### Phase 3: Remove Dead Code +The `annotation` parameter is generic — it receives either a countdown string `"(3s)"`, an elapsed string `"(5s)"`, or `None` for persistent banners. This is computed by `process_banner()` which handles the lifecycle logic. -1. Delete the `#[allow(dead_code)]` annotations from `theme.rs` if the dead `MessageType` was removed in Phase 1 -2. Remove any helper functions that were only used for the old error rendering pattern (if any exist) -3. Run `cargo clippy` to find any remaining unused code related to the old pattern +### Visual Structure ---- +``` ++-----------------------------------------------------------------------+ +| [Icon] Message text here [5s] [x] | ++-----------------------------------------------------------------------+ +``` -## 5. What Stays Unchanged +- Frame: `DashColors::message_background_color()` fill, `DashColors::message_color()` border +- Icon: Unicode character (⚠ for Error/Warning, ✓ for Success, ℹ for Info) +- Text: `DashColors::message_color()` foreground +- Annotation: `DashColors::text_secondary()` color, `Typography::body_small()` font +- Dismiss: `ui.small_button("x")` +- Spacing: `Spacing::SM` below each banner -| Concern | Status | Rationale | -|---------|--------|-----------| -| **Status enums** (Pattern 2, e.g., `TransferCreditsStatus::ErrorMessage(String)`) | Keep as-is | These track screen workflow state. The screen can use both a status enum for flow control and `MessageBanner` for display. When entering the `ErrorMessage` state, the screen calls `self.banner.set_message(...)`. | -| **`show_success_screen()` helper** (Pattern 6, `src/ui/helpers.rs`) | Keep as-is | This renders a full-page success view with detailed info, not a banner. Different purpose entirely. | -| **Inline validation** (Pattern 8, e.g., `AmountInput` error messages) | Keep as-is | These are field-level validation hints rendered next to the input. They are not screen-level messages. | -| **`ConfirmationDialog`** | Keep as-is | Modal dialog for confirming actions. Orthogonal to message banners. | -| **BackendTask / TaskResult / AppState** | Keep as-is | The task routing in `app.rs` calls `display_message()` on the visible screen. This works identically with `MessageBanner` -- the screen's `display_message` impl just delegates to `banner.set_message()`. | -| **`AppAction` enum** | Keep as-is | No new action variants needed. | -| **`BackendTaskSuccessResult`** | Keep as-is | No changes to result types. | -| **`component_trait.rs` (`Component` / `ComponentResponse`)** | Not implemented by `MessageBanner` | `Component` trait is for input widgets that produce domain values (`AmountInput` → `Amount`). `MessageBanner` is a display widget that consumes data. It follows the pattern's conventions (private fields, builder, `show()`, DashColors) without the trait. Same approach as `StyledButton`, `GradientHeading`, `InfoPopup`. | +All colors are resolved via `DashColors` methods — zero hardcoded `Color32` values. --- -## 6. Code Examples: Before/After Migration - -### Example: `TopUpIdentityScreen` +## 6. AppState Integration -#### Before (current code) +`AppState::update()` in `src/app.rs` sets global banners automatically for all task results: -**Struct definition** (`src/ui/identities/top_up_identity_screen/mod.rs:44-66`): -```rust -pub struct TopUpIdentityScreen { - pub identity: QualifiedIdentity, - // ... other fields ... - error_message: Option, - // ... -} -``` - -**Constructor:** -```rust -impl TopUpIdentityScreen { - pub fn new(qualified_identity: QualifiedIdentity, app_context: &Arc) -> Self { - Self { - // ... - error_message: None, - // ... - } - } -} ``` +TaskResult::Error(message) + → MessageBanner::set_global(ctx, &message, MessageType::Error) + → screen.display_message(&message, MessageType::Error) // for side-effects -**Error rendering in `ui()`** (lines 531-552): -```rust -// Display error message at the top, outside of scroll area -if let Some(error_message) = self.error_message.clone() { - let message_color = egui::Color32::from_rgb(255, 100, 100); - - ui.horizontal(|ui| { - egui::Frame::new() - .fill(message_color.gamma_multiply(0.1)) - .inner_margin(egui::Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, message_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(egui::RichText::new(&error_message).color(message_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.error_message = None; - } - }); - }); - }); - ui.add_space(10.0); -} +TaskResult::Success (default) + → MessageBanner::set_global(ctx, "Success", MessageType::Success) + → screen.display_task_result(result) ``` -**`display_message` impl** (lines 430-435): -```rust -fn display_message(&mut self, message: &str, message_type: MessageType) { - if message_type == MessageType::Error { - self.error_message = Some(format!("Error topping up identity: {}", message)); - // Reset step so UI is not stuck on waiting messages - let mut step = self.step.write().unwrap(); - if *step == WalletFundedScreenStep::WaitingForPlatformAcceptance - || *step == WalletFundedScreenStep::WaitingForAssetLock - { - *step = WalletFundedScreenStep::ChooseFundingMethod; - } - } -} -``` +The call to `screen.display_message()` is retained alongside `set_global` so screens can perform side-effects (e.g., resetting step state on error, clearing refresh handles) without being responsible for rendering. -#### After (migrated) +### Rendering Point -**Struct definition:** -```rust -use crate::ui::components::MessageBanner; +`island_central_panel()` in `src/ui/components/styled.rs` calls `MessageBanner::show_global(ui)` before screen content. All screens render through this function, providing a single consistent insertion point. -pub struct TopUpIdentityScreen { - pub identity: QualifiedIdentity, - // ... other fields ... - banner: MessageBanner, // replaces error_message: Option - // ... -} -``` +--- -**Constructor:** -```rust -Self { - // ... - banner: MessageBanner::new(), // replaces error_message: None - // ... -} -``` +## 7. Usage Example: IdentitiesScreen -**Rendering in `ui()`** (replaces the 20-line block): -```rust -// Display message banner at the top, outside of scroll area -self.banner.show(ui); -``` +Demonstrates the `BannerHandle` + elapsed-time pattern for a long-running refresh operation: -**`display_message` impl** (keeps custom step-reset logic): ```rust -fn display_message(&mut self, message: &str, message_type: MessageType) { - self.banner.set_message(message, message_type); - if message_type == MessageType::Error { - // Reset step so UI is not stuck on waiting messages - let mut step = self.step.write().unwrap(); - if *step == WalletFundedScreenStep::WaitingForPlatformAcceptance - || *step == WalletFundedScreenStep::WaitingForAssetLock - { - *step = WalletFundedScreenStep::ChooseFundingMethod; - } - } -} -``` - -Note that the error message prefix `"Error topping up identity: "` is dropped. The banner's visual styling (red color, error icon) already communicates that it is an error. The backend task error string itself is descriptive enough. - -### Example: Simple Screen (no custom `display_message`) +// Struct: stores handle instead of separate refreshing/timestamp fields +refresh_banner: Option, -Many screens have no custom `display_message` override and rely on the default no-op. After migration, these screens get a one-line `display_message`: +// Starting refresh (in ui()): +let handle = MessageBanner::set_global(ctx, "Refreshing identities...", MessageType::Info); +handle.with_elapsed(); +self.refresh_banner = Some(handle); -```rust -fn display_message(&mut self, message: &str, message_type: MessageType) { - self.banner.set_message(message, message_type); +// On success (in display_task_result() — no ctx parameter available): +if let Some(handle) = self.refresh_banner.take() { + handle.clear(); } -``` - -And a one-line render call in `ui()`: +MessageBanner::set_global( + self.app_context.egui_ctx(), + "Successfully refreshed identity", + MessageType::Success, +); -```rust -self.banner.show(ui); +// On error (in display_message() — side-effect only): +if let MessageType::Error = message_type + && let Some(handle) = self.refresh_banner.take() +{ + handle.clear(); +} ``` --- -## 7. Architectural Decisions Summary +## 8. Key Design Decisions | Decision | Rationale | |----------|-----------| -| Per-screen ownership (not global) | Matches egui immediate-mode model. No shared mutable state. Each screen is self-contained. | -| `Instant` for timing (not egui frame time) | Monotonic, works correctly even if frames are missed while screen is not visible. | -| Follows Component Design Pattern conventions, not `Component` trait | `MessageBanner` is a display widget (consumes data), not an input widget (produces domain values). It follows the pattern's conventions (private fields, builder, `show()`, DashColors) like `StyledButton`/`InfoPopup`. | -| All colors via `DashColors` methods | Zero hardcoded `Color32` values. New `DashColors::message_color()`, `message_background_color()`, and `info_color()` methods ensure single source of truth. If the design system is updated, the banner reflects changes automatically. | -| Delete dead `theme.rs::MessageType`, consolidate into `DashColors` | The dead enum had light-mode-only `color()`/`background_color()` methods. The replacement `DashColors::message_color(type, dark_mode)` and `DashColors::message_background_color(type, dark_mode)` are dark-mode-aware and reusable by any component. | -| No accessor method on `ScreenLike` | Avoids coupling the trait to a specific field name. The two-line `display_message` boilerplate is acceptable. | -| Incremental migration (one screen at a time) | Reduces risk. Each migration is a small, reviewable change. Old and new patterns coexist during migration. | +| Global banners via egui context data | All screens render through `island_central_panel()`, making global placement consistent. Eliminates per-screen rendering boilerplate. | +| Multi-message support (max 5) | Backend tasks may produce multiple results; they should all be visible. Cap prevents unbounded growth. | +| Text-based dedup at creation, key-based lookup after | Prevents duplicate "Success" banners from rapid task completions while allowing handle-based text updates. | +| `BannerHandle` as `'static` struct | Handles can be stored in screen fields (e.g., `refresh_banner: Option`) without lifetime issues. | +| `elapsed()` looks up from context data | Avoids data redundancy — `created_at` lives only in `BannerState`, not duplicated on the handle. | +| All handle methods return `Option` | Handles may outlive their banners (dismissed, expired). Callers must handle the `None` case. | +| `Instant` for timing | Monotonic, immune to system clock changes. Correct for timeout and elapsed-time logic. | +| Per-instance implements `Component` trait | `DomainType = BannerStatus` allows screens to react to banner lifecycle events (dismiss, timeout) through the standard component interface. | +| All colors via `DashColors` methods | Zero hardcoded colors. Theme changes propagate automatically. | +| Retained `screen.display_message()` call | Screens still need side-effect hooks (reset step state, clear refresh handles). The global banner handles display; `display_message()` handles side-effects only. | --- -## 8. Alternatives Considered - -### Alternative A: Global App-Level Banner (Rejected) - -Render the banner in `AppState::update()` above all screens, eliminating per-screen ownership. - -**Pros:** Zero migration effort for backend task errors — `AppState` would call `banner.set_message()` directly instead of routing through `display_message()`. - -**Rejected because:** Screens render inside panels with different layouts (left sidebar, main content, modals). A global banner can't know the correct placement within each screen's layout. Also, screens that set validation errors during `ui()` would still need their own banner. Result: two systems instead of one. - -### Alternative B: Implement `Component` Trait (Rejected) - -Make `MessageBanner` implement `Component`. - -**Pros:** Perfectly uniform with `AmountInput` and `ConfirmationDialog`. - -**Rejected because:** `Component` requires `DomainType` (the value the component produces), `ComponentResponse` with `has_changed()`/`changed_value()`/`update()`, and `current_value()`. A display widget has no meaningful domain value to produce. Using `DomainType = ()` and `changed_value() -> &Option<()>` would be semantically empty. The pattern doc itself describes components that "handle wallet selection" or "manage passwords" — input components. Display-only widgets (`StyledButton`, `GradientHeading`, `InfoPopup`) don't use the trait either. - -### Alternative C: Add `fn banner() -> &mut MessageBanner` to ScreenLike (Rejected) - -Add an accessor method to the trait so `AppState` could call `screen.banner().set_message()` directly. - -**Pros:** Eliminates the two-line `display_message` boilerplate per screen. - -**Rejected because:** Forces every `ScreenLike` implementor to have a `MessageBanner` field (including screens that legitimately don't need messages, like `ProofLogScreen`). Changing the trait contract for 50+ screens simultaneously increases migration risk. +## 9. Migration Status -### Alternative D: Use `DateTime` for Timing (Rejected) +3 of ~50 screens migrated as proof-of-concept: -8 screens already use `DateTime` for timed messages. +| Screen | Old Pattern | Migration Notes | +|--------|-------------|-----------------| +| `TopUpIdentityScreen` | Framed banner + Dismiss | Removed `error_message` field and 20-line Frame block. `display_message()` retains step-reset side-effect. | +| `IdentitiesScreen` | Timed badge with DateTime | Removed `backend_message` field and helpers. Uses `BannerHandle` with `with_elapsed()` for refresh tracking. | +| `MintTokensScreen` | Bare colored_label + status enum | Changed `ErrorMessage(String)` to `Error` (no payload). Banner displays via global API. | -**Rejected because:** `Instant` is monotonic (immune to system clock changes), simpler (no chrono dependency needed), and purpose-built for duration measurement. `DateTime` is correct for display timestamps but wrong for timeout logic. +Old and new patterns coexist without conflict because the global banner renders above screen content via `island_central_panel`. --- -## 9. Implementation Notes - -This section documents how the implementation diverged from the original design and the patterns that emerged during development. - -### 9.1 Architecture Deviation: Global Banner Instead of Per-Screen - -The original design (Sections 2-7 above) specified **per-screen ownership** where each screen holds a `MessageBanner` instance. The implementation instead uses a **global banner** stored in egui context data and rendered centrally by `island_central_panel()`. - -**Why the change:** The user requested centralized error handling where backend task errors display automatically without requiring every screen to wire up `display_message()` overrides. With a global banner, `AppState::update()` sets the message once and it appears on whatever screen is currently visible. No per-screen rendering code is needed for basic error/success display. - -**Why the original concern was resolved:** Section 8, Alternative A rejected the global approach because "screens render inside panels with different layouts" and "a global banner can't know the correct placement." In practice, all 58 screens render through `island_central_panel()` (in `src/ui/components/styled.rs`), which provides a single consistent insertion point at the top of the content area, before screen content. This makes the placement concern moot. - -### 9.2 Global API - -The `MessageBanner` component (`src/ui/components/message_banner.rs`) exposes four static methods for global state management. State is stored in egui's temporary context data using `egui::Id::new("__global_message_banner")`. - -| Method | Signature | Purpose | -|--------|-----------|---------| -| `set_global` | `(ctx: &egui::Context, text: &str, message_type: MessageType)` | Sets or replaces the global banner message. Empty text clears. | -| `clear_global` | `(ctx: &egui::Context)` | Removes the global banner state from context data. | -| `show_global` | `(ui: &mut egui::Ui)` | Renders the banner, handles auto-dismiss and countdown. Called by `island_central_panel`. | -| `has_global` | `(ctx: &egui::Context) -> bool` | Checks whether a global message exists (useful for tests). | - -The underlying `BannerState` struct is `#[derive(Clone)]` because egui context data requires `Clone`. It stores `text: String`, `message_type: MessageType`, and `created_at: Instant`. - -### 9.3 AppState Integration - -`AppState::update()` in `src/app.rs` sets the global banner automatically for all task results: - -``` -TaskResult::Error(message) - -> MessageBanner::set_global(ctx, &message, MessageType::Error) - -> screen.display_message(&message, MessageType::Error) // for side-effects - -TaskResult::Success (default branch) - -> MessageBanner::set_global(ctx, "Success", MessageType::Success) - -> screen.display_task_result(result) - -TaskResult::Success (specific: UpdatedThemePreference) - -> MessageBanner::set_global(ctx, "Theme preference updated successfully", MessageType::Success) - -TaskResult::Success (specific: CastScheduledVote) - -> MessageBanner::set_global(ctx, "Successfully cast scheduled vote", MessageType::Success) -``` - -The call to `screen.display_message()` is retained alongside `set_global` so that screens can still perform side-effects (e.g., resetting step state on error) without being responsible for rendering. - -### 9.4 Screen Migration Pattern - -Migrated screens follow this pattern: - -1. **Remove error display fields** (`error_message: Option`, `backend_message: Option<(String, MessageType, DateTime)>`, etc.) -2. **Remove rendering code** (the `Frame`/`colored_label` blocks in `ui()`) -3. **Simplify `display_message()`** to only retain side-effect logic (e.g., resetting step state). The method no longer needs to store the error text since the global banner handles display. -4. **Add `pending_banner_message` field** for messages that originate in `display_task_result()`, which does not receive the egui `Context`. These are flushed at the top of `ui()`: - -```rust -// In display_task_result(): -self.pending_banner_message = Some(("Successfully refreshed identity".to_string(), MessageType::Success)); - -// At the top of ui(): -if let Some((text, msg_type)) = self.pending_banner_message.take() { - MessageBanner::set_global(ctx, &text, msg_type); -} -``` - -For validation errors set during `ui()`, screens call `MessageBanner::set_global(ctx, ...)` directly since they have the egui `Context`. - -### 9.5 Per-Instance API - -The `MessageBanner` struct with per-instance methods (`new()`, `set_message()`, `clear()`, `show()`, `has_message()`) still exists alongside the global API. It shares rendering logic with the global path via a private `render_banner()` function. - -This per-instance API is available for potential future use cases such as screen-local banners displayed alongside the global one (e.g., a screen that needs a secondary notification area). Currently no screens use the per-instance API; its methods carry `#[allow(dead_code)]`. - -### 9.6 Migration Status +## 10. Pre-Migration Analysis -3 of ~50 screens have been migrated as a proof-of-concept, each demonstrating a different migration path: +Before the MessageBanner was implemented, the codebase had: -| Screen | Pattern | Migration Notes | -|--------|---------|-----------------| -| `TopUpIdentityScreen` | A (Framed Banner + Dismiss) | Removed `error_message` field and 20-line Frame rendering block. `display_message()` retains step-reset side-effect. | -| `IdentitiesScreen` | B (Timed Badge with DateTime) | Removed `backend_message` field, `check_message_expiration()`, and `dismiss_message()` helpers. Uses `pending_banner_message` for messages from `display_task_result()`. | -| `MintTokensScreen` | D (Bare colored_label + status enum) | Changed `MintTokensStatus::ErrorMessage(String)` to `MintTokensStatus::Error` (no payload). Uses `pending_banner_message` for validation errors set during construction and in `confirmation_ok()`. | +- **~50 screens** with no unified message rendering +- **4 distinct rendering patterns**: Framed Banner, Timed Badge, Status Enum, Bare colored_label +- **8+ different error colors**: `Color32::RED`, `DARK_RED`, `from_rgb(255,100,100)`, `from_rgb(220,80,80)`, `DashColors::error_color()`, `DashColors::ERROR`, etc. +- **Two competing `MessageType` enums**: active 3-variant in `mod.rs`, dead 4-variant in `theme.rs` -The remaining ~47 screens continue to use their existing per-screen error rendering. Old and new patterns coexist without conflict because the global banner renders above screen content via `island_central_panel`. +The dead `theme.rs::MessageType` was deleted. The active enum gained a `Warning` variant. Color methods were consolidated into `DashColors::message_color()` and `message_background_color()`. A new `info_color()` helper was added to complement the existing `error_color()`/`success_color()`/`warning_color()` methods. diff --git a/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md b/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md index 45123dd03..cd325a0cd 100644 --- a/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md +++ b/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md @@ -2,20 +2,25 @@ ## 1. Overview -The `MessageBanner` is a self-contained egui component that replaces all ad-hoc `error_message: Option` fields across screens. Each screen owns one `MessageBanner` instance (not global). It displays a single message at a time with consistent styling based on severity. +The `MessageBanner` renders user-facing messages with consistent styling based on severity. It operates in two modes: + +- **Global mode**: Multiple banners stored in egui context data, rendered centrally by `island_central_panel()` before screen content. This is the primary mode — `AppState::update()` sets banners automatically for all backend task results. +- **Per-instance mode**: A screen owns a `MessageBanner` struct for screen-local messages. Shares rendering logic with global mode. + +Multiple messages can be displayed simultaneously (capped at 5). Each banner is independently dismissible and independently timed. --- ## 2. Severity Levels -| Severity | Persistence | Dismiss | Use Case | -|----------|---------------------|--------------------|-----------------------------------------------| -| Error | Persistent | Manual only | Task failures, validation errors | -| Warning | Persistent | Manual only | Risky actions, degraded state | -| Success | Auto-dismiss ~5s | Manual or auto | Completed operations | -| Info | Auto-dismiss ~5s | Manual or auto | Informational feedback, neutral status updates | +| Severity | Default Persistence | Dismiss | Use Case | +|----------|-------------------|---------|----------| +| Error | Persistent | Manual only | Task failures, validation errors | +| Warning | Persistent | Manual only | Risky actions, degraded state | +| Success | Auto-dismiss 5s | Manual or auto | Completed operations | +| Info | Auto-dismiss 5s | Manual or auto | Informational feedback, status updates | -All four types display a dismiss button. Success and Info also count down and auto-clear. +All four types display a dismiss button (`x`). Success and Info also show a countdown label. Any banner can be switched to elapsed-time mode via `handle.with_elapsed()`, which disables auto-dismiss and shows time since creation. --- @@ -25,106 +30,67 @@ All four types display a dismiss button. Success and Info also count down and au ``` +-----------------------------------------------------------------------+ -| [Icon] Message text here [5s] [Dismiss] | +| [Icon] Message text here [5s] [x] | +-----------------------------------------------------------------------+ ``` -The banner is a single horizontal row inside an `egui::Frame` with: -- **Corner radius**: 6px (`Shape::RADIUS_SM`) -- **Inner margin**: 10px horizontal, 8px vertical (matching existing pattern in `top_up_identity_screen`) -- **Outer spacing**: 10px below the banner (`ui.add_space(10.0)`) -- **Full available width**: The frame should expand to `ui.available_width()` +The banner is a horizontal row inside an `egui::Frame` with: +- **Corner radius**: `Shape::RADIUS_SM` (6px) +- **Inner margin**: 10px horizontal, 8px vertical +- **Outer spacing**: `Spacing::SM` below each banner +- **Full available width**: Frame expands to `ui.available_width()` ### 3.2 Content Arrangement (left to right) -1. **Icon character** (Unicode text, not image): Provides at-a-glance severity recognition. - - Error: `!` (exclamation in circle, or literal `!` if unicode unavailable) - - Warning: `!` (triangle-style -- visually distinct from error via color) - - Success: Checkmark character - - Info: `i` (info style) - - Font size: `Typography::SCALE_BASE` (16px), bold - - Color: Same as the text color for that severity +1. **Icon** (Unicode): `⚠` (Error/Warning), `✓` (Success), `ℹ` (Info) + - Color: Same as text color for severity + - Style: `RichText::strong()` 2. **4px gap** (`Spacing::XS`) -3. **Message text**: Left-aligned, wrapping permitted for long messages. - - Font: `Typography::body()` (16px proportional) - - Color: Severity-specific foreground color (see Section 3.3) +3. **Message text**: Left-aligned. Long text may be clipped within the horizontal layout. + - Font: Default egui label font (matches app-wide body text size) + - Color: Severity-specific foreground -4. **Flexible space** (push remaining elements to the right) +4. **Flexible space** (right-to-left layout for remaining elements) -5. **Countdown label** (Success and Info only): Shows remaining seconds, e.g., `(3s)`. +5. **Annotation** (optional): Shows remaining seconds `(3s)` or elapsed seconds `(5s)` - Font: `Typography::body_small()` (14px) - - Color: Same as message text, at reduced opacity or using `text_secondary` + - Color: `DashColors::text_secondary(dark_mode)` -6. **Dismiss button**: Small text button labeled with an `x` character. - - Uses `ui.small_button("x")` - - Clicking sets the banner state to None +6. **Dismiss button**: `ui.small_button("x")` ### 3.3 Color Palette -All colors are resolved through `DashColors` methods — the banner contains zero hardcoded color values. The banner uses a **tinted background + colored border + colored text** approach. - -#### Color Resolution via DashColors - -| Purpose | DashColors Method | Description | -|---|---|---| -| Text & border | `DashColors::message_color(type, dark_mode)` | Delegates to `error_color()` / `success_color()` / `warning_color()` / `info_color()` | -| Background tint | `DashColors::message_background_color(type, dark_mode)` | Severity color at 8% alpha (light) or 12% alpha (dark) | -| Countdown text | `DashColors::text_secondary(dark_mode)` | Standard secondary text color | - -#### Resolved Values (for visual reference) - -**Light Mode:** - -| Severity | Background | Text & Border | -|----------|-----------|---------------| -| Error | `ERROR` at 8% alpha | `DashColors::error_color(false)` → `DARK_RED` | -| Warning | `WARNING` at 8% alpha | `DashColors::warning_color(false)` → dark amber | -| Success | `SUCCESS` at 8% alpha | `DashColors::success_color(false)` → `DARK_GREEN` | -| Info | `INFO` at 8% alpha | `DashColors::info_color(false)` → `DEEP_BLUE` | - -**Dark Mode:** - -| Severity | Background | Text & Border | -|----------|-----------|---------------| -| Error | lighter red at 12% alpha | `DashColors::error_color(true)` → `rgb(255, 100, 100)` | -| Warning | lighter amber at 12% alpha | `DashColors::warning_color(true)` → `rgb(255, 200, 100)` | -| Success | muted green at 12% alpha | `DashColors::success_color(true)` → `rgb(80, 160, 80)` | -| Info | light blue at 12% alpha | `DashColors::info_color(true)` → `rgb(100, 180, 255)` | - -Dark mode uses higher alpha backgrounds (12% vs 8%) to maintain visibility against dark surfaces. +All colors resolved through `DashColors` — zero hardcoded values. -#### Border Stroke -- Width: `Shape::BORDER_WIDTH` (1px) -- Color: `DashColors::message_color(type, dark_mode)` — same as text color +| Purpose | Method | +|---------|--------| +| Text & border | `DashColors::message_color(type, dark_mode)` | +| Background tint | `DashColors::message_background_color(type, dark_mode)` | +| Annotation text | `DashColors::text_secondary(dark_mode)` | -### 3.4 Typography - -- Icon: `Typography::SCALE_BASE` (16px), bold (`RichText::strong()`) -- Message body: `Typography::body()` (16px), normal weight -- Countdown: `Typography::body_small()` (14px), normal weight, secondary text color -- Dismiss button: egui default `small_button` styling +Background uses low alpha (8% light, 12% dark) for subtle tinting. Border uses `Shape::BORDER_WIDTH` (1px) in the foreground color. --- ## 4. Placement -The banner renders at the **top of the screen's content area**, before the `ScrollArea`. This matches the existing convention in `top_up_identity_screen/mod.rs:531-552` and `add_new_identity_screen/mod.rs:1071-1092`. +Global banners render at the top of the content area inside `island_central_panel()`, before any screen content: ``` +--------------------------------------------------+ | Top Panel (header / navigation) | +--------------------------------------------------+ -| Left Panel | [ MessageBanner ] | <-- here -| | +----- ScrollArea -----+ | -| | | Screen content | | -| | | ... | | -| | +----------------------+ | +| Left Panel | [ Banner 1 ] | +| | [ Banner 2 ] | +| | +----- Screen Content -----+ | +| | | ... | | +| | +--------------------------+ | +--------------------------------------------------+ ``` -The banner must be rendered **outside** the `ScrollArea` so it remains visible regardless of scroll position. This is consistent with current best practice in the codebase. +Banners remain visible regardless of scroll position because they render outside `ScrollArea`. --- @@ -132,140 +98,66 @@ The banner must be rendered **outside** the `ScrollArea` so it remains visible r ### 5.1 Showing a Message -Calling `banner.set_message("text", MessageType::Error)` replaces any currently displayed message. There is no queue. The new message immediately takes effect. - -For auto-dismissing types (Success, Info), the component records the timestamp when the message was set (using `Instant::now()` or egui frame time). +`MessageBanner::set_global(ctx, text, type)` adds a banner. If a banner with the same text already exists, the call is deduplicated (returns a handle to the existing banner). ### 5.2 Auto-Dismiss (Success, Info) -- Duration: 5 seconds -- The countdown label shows remaining whole seconds: `(5s)`, `(4s)`, ..., `(1s)` -- When the timer expires, the message clears automatically on the next frame -- The screen must call `banner.show(ui)` each frame (standard egui immediate mode) -- The component internally checks elapsed time and clears itself - -### 5.3 Manual Dismiss - -- All severity types display a dismiss button -- Clicking the dismiss button immediately clears the message -- No confirmation needed - -### 5.4 Message Replacement +- Default duration: 5 seconds +- Countdown label: `(5s)`, `(4s)`, ..., `(1s)` +- Banner clears automatically when timer expires +- Component requests repaint every 1s for countdown updates -- Setting a new message while one is showing replaces it immediately -- If the old message was Error (persistent) and the new one is Success (auto-dismiss), the Success behavior applies -- Timer resets on replacement +### 5.3 Elapsed-Time Mode -### 5.5 Screen Navigation +Calling `handle.with_elapsed()` switches a banner to elapsed-time display: +- Shows `(0s)`, `(1s)`, `(2s)`, ... counting up +- Auto-dismiss is disabled (banner persists until manually cleared) +- Used for long-running operations (e.g., identity refresh) -- When the user navigates away from a screen and returns, persistent messages (Error, Warning) should still be visible if the screen struct was retained (root screens in `main_screens` BTreeMap) -- Auto-dismiss messages that expired while the screen was not visible should be gone on return -- Modal/detail screens pushed onto `screen_stack` are destroyed when popped, so their messages naturally disappear +### 5.4 Manual Dismiss ---- +- All severity types display a dismiss (`x`) button +- Clicking clears that specific banner immediately +- Other banners are unaffected -## 6. Component API (Behavioral Spec) +### 5.5 Message Replacement -This describes the public interface the component should expose. Not a full Rust implementation, but a behavioral contract for the architect and implementer. +`MessageBanner::replace_global(ctx, old_text, new_text, type)` finds a banner by old text and replaces it. If old text is not found, the new text is added as a new banner. If new text is empty, the old banner is removed. -``` -MessageBanner - State: - - message: Option<(String, MessageType, Instant)> - - Methods: - - new() -> Self // empty, no message - - set_message(text: &str, msg_type: MessageType) // set/replace message - - clear() // manually clear - - show(ui: &mut Ui) // render; auto-dismiss check happens here - - MessageType (unified, replaces both existing enums): - - Error - - Warning - - Success - - Info -``` +### 5.6 BannerHandle Lifecycle -The component does NOT implement the `Component` trait from `component_trait.rs` because it has no domain data to bind via `update()`. It is a simpler display-only widget. Screens call `show(ui)` and `set_message(...)` directly. +Handles returned by `set_global`/`replace_global` can outlive their banners. All handle methods return `Option` — `None` means the banner has been dismissed, expired, or cleared. --- -## 7. Integration Points - -### 7.1 ScreenLike Trait - -The existing `display_message(&mut self, message: &str, message_type: MessageType)` method on `ScreenLike` is the integration point. Screens that adopt `MessageBanner` implement it as: - -``` -fn display_message(&mut self, message: &str, message_type: MessageType) { - self.banner.set_message(message, message_type); -} -``` - -### 7.2 Replacing Existing Fields - -Each screen replaces its ad-hoc fields: -- `error_message: Option` -> removed -- `info_message: Option` -> removed -- `message: Option<(String, MessageType)>` -> removed -- `backend_message: Option<(String, MessageType, DateTime)>` -> removed - -All replaced by a single: -- `banner: MessageBanner` - -### 7.3 Sync Error Display (Validation) - -For validation errors set during `ui()`: - -``` -if some_validation_fails { - self.banner.set_message("Invalid input: ...", MessageType::Error); -} -``` - ---- - -## 8. Edge Cases +## 6. Edge Cases | Scenario | Behavior | |----------|----------| -| Very long message (300+ chars) | Text wraps within the frame. Frame grows vertically. No truncation. | -| Empty string message | Treated as no message; banner not shown. | -| Rapid message replacement | Each call to `set_message` replaces immediately. No debounce. | -| Multiple error fields on one screen | All consolidated into a single banner. If multiple errors need display, concatenate them with newlines before calling `set_message`. | -| Screen with multiple independent sections | Each section could own its own `MessageBanner` if needed, but the default is one per screen. | -| Theme change while message is showing | Colors re-evaluate each frame via `ui.ctx().style().visuals.dark_mode`. No stale colors. | +| Very long message (300+ chars) | Text may be clipped within the horizontal layout. No explicit truncation or wrapping. | +| Empty string message | `set_global("")` is a no-op. Per-instance `set_message("")` clears. | +| Duplicate text | `set_global` returns handle to existing banner (idempotent). | +| More than 5 messages | Oldest message is evicted. | +| Rapid message replacement | Each call replaces/adds immediately. No debounce. | +| Theme change while showing | Colors re-evaluated each frame via `DashColors`. No stale colors. | +| Handle used after banner cleared | All methods return `None`. No panic. | +| Banner expired while screen not visible | Expired on next `show_global()` call when screen becomes visible. | --- -## 9. Accessibility +## 7. Accessibility -- **Color contrast**: All text/background combinations meet WCAG 2.1 AA contrast ratio (4.5:1 minimum). The colored text on tinted backgrounds achieves this because the backgrounds are near-transparent (8-12% alpha) over the page background. -- **Not color-only**: The icon character provides a non-color severity indicator (distinct shapes for error vs warning vs success vs info). -- **Text is selectable**: egui labels allow text selection by default. -- **Keyboard**: The dismiss button is focusable and activatable via keyboard in egui's default tab-order. No special focus management needed. -- **Screen readers**: Not directly applicable (egui does not have native screen reader support), but the text content is programmatically accessible via egui's accessibility layer if enabled. +- **Color contrast**: Text on near-transparent backgrounds (8-12% alpha) meets WCAG 2.1 AA (4.5:1 minimum). +- **Not color-only**: Icon character provides non-color severity indicator. +- **Text selectable**: egui labels allow text selection. +- **Keyboard**: Dismiss button is focusable via egui's default tab-order. --- -## 10. What This Spec Does NOT Cover +## 8. What This Spec Does NOT Cover -- Toast/notification stacking (out of scope -- one message per screen) -- Animation or transitions (egui does not support CSS-style transitions; show/hide is instant) +- Toast/notification stacking beyond the 5-banner cap +- Animation or transitions (egui show/hide is instant) - Sound or haptic feedback - Message persistence across app restarts -- Global overlay banners -- Changes to BackendTask/TaskResult/AppState architecture - ---- - -## 11. Migration Strategy (UX Perspective) - -The visual result after migration should be: -1. Every screen shows messages in exactly the same visual style -2. Error messages appear in the same position (top of content, before scroll area) -3. Success messages auto-clear after 5 seconds with a visible countdown -4. No more inline `colored_label` errors scattered at arbitrary positions in screen layouts -5. The Warning severity becomes available for the first time (currently missing from `MessageType` in `mod.rs`) - -Screens that currently use `colored_label` for inline validation hints (e.g., "Field is required") are a separate concern and should remain inline. The `MessageBanner` replaces only the screen-level status/result messages. +- Changes to BackendTask/TaskResult/AppState routing architecture From 54a9e27372926c8e1f9ca42027dbc87df89737c2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:52:01 +0100 Subject: [PATCH 05/19] chore: self review --- src/ui/components/message_banner.rs | 35 +++++--------- tests/kittest/message_banner.rs | 71 +++++++++++++++++++++++++---- 2 files changed, 73 insertions(+), 33 deletions(-) diff --git a/src/ui/components/message_banner.rs b/src/ui/components/message_banner.rs index b7e4d915b..476f9c6d3 100644 --- a/src/ui/components/message_banner.rs +++ b/src/ui/components/message_banner.rs @@ -93,14 +93,10 @@ impl BannerHandle { /// Returns `None` if the banner no longer exists. pub fn set_message(&self, text: &str) -> Option<&Self> { let mut banners = get_banners(&self.ctx); - let found = if let Some(b) = banners.iter_mut().find(|b| b.key == self.key) { - b.text = text.to_string(); - true - } else { - false - }; + let b = banners.iter_mut().find(|b| b.key == self.key)?; + b.text = text.to_string(); set_banners(&self.ctx, banners); - found.then_some(self) + Some(self) } /// Override the auto-dismiss duration for this banner. @@ -108,15 +104,11 @@ impl BannerHandle { /// Returns `None` if the banner no longer exists. pub fn with_auto_dismiss(&self, duration: Duration) -> Option<&Self> { let mut banners = get_banners(&self.ctx); - let found = if let Some(b) = banners.iter_mut().find(|b| b.key == self.key) { - b.auto_dismiss_after = Some(duration); - b.created_at = Instant::now(); - true - } else { - false - }; + let b = banners.iter_mut().find(|b| b.key == self.key)?; + b.auto_dismiss_after = Some(duration); + b.created_at = Instant::now(); set_banners(&self.ctx, banners); - found.then_some(self) + Some(self) } /// Enable elapsed-time display on this banner. Disables auto-dismiss @@ -124,15 +116,11 @@ impl BannerHandle { /// Returns `None` if the banner no longer exists. pub fn with_elapsed(&self) -> Option<&Self> { let mut banners = get_banners(&self.ctx); - let found = if let Some(b) = banners.iter_mut().find(|b| b.key == self.key) { - b.show_elapsed = true; - b.auto_dismiss_after = None; - true - } else { - false - }; + let b = banners.iter_mut().find(|b| b.key == self.key)?; + b.show_elapsed = true; + b.auto_dismiss_after = None; set_banners(&self.ctx, banners); - found.then_some(self) + Some(self) } /// Remove this banner immediately. @@ -267,6 +255,7 @@ impl MessageBanner { b.message_type = message_type; b.created_at = Instant::now(); b.auto_dismiss_after = default_auto_dismiss(message_type); + b.show_elapsed = false; } else if let Some(existing) = banners.iter().find(|b| b.text == new_text) { key = existing.key; } else { diff --git a/tests/kittest/message_banner.rs b/tests/kittest/message_banner.rs index 26db9c1d0..40f4c86a9 100644 --- a/tests/kittest/message_banner.rs +++ b/tests/kittest/message_banner.rs @@ -94,9 +94,21 @@ fn test_banner_renders_all_types() { }); harness.run(); // Message text, icon, and dismiss button should all be present - assert!(harness.query_by_label(text).is_some(), "Missing text for {:?}", msg_type); - assert!(harness.query_by_label(icon).is_some(), "Missing icon for {:?}", msg_type); - assert!(harness.query_by_label("x").is_some(), "Missing dismiss button for {:?}", msg_type); + assert!( + harness.query_by_label(text).is_some(), + "Missing text for {:?}", + msg_type + ); + assert!( + harness.query_by_label(icon).is_some(), + "Missing icon for {:?}", + msg_type + ); + assert!( + harness.query_by_label("x").is_some(), + "Missing dismiss button for {:?}", + msg_type + ); } } @@ -152,8 +164,7 @@ fn test_deduplication() { fn test_replace_global_finds_and_replaces() { let ctx = egui::Context::default(); - let original_handle = - MessageBanner::set_global(&ctx, "Generic success", MessageType::Success); + let original_handle = MessageBanner::set_global(&ctx, "Generic success", MessageType::Success); MessageBanner::set_global(&ctx, "An error", MessageType::Error); // Replace the generic one @@ -434,10 +445,50 @@ fn test_handle_with_auto_dismiss_on_live_banner() { let ctx = egui::Context::default(); let handle = MessageBanner::set_global(&ctx, "Dismissable", MessageType::Error); - assert!( - handle - .with_auto_dismiss(Duration::from_secs(30)) - .is_some() - ); + assert!(handle.with_auto_dismiss(Duration::from_secs(30)).is_some()); assert!(handle.elapsed().is_some()); } + +/// Test that exceeding MAX_BANNERS (5) evicts the oldest banner. +#[test] +fn test_max_banners_eviction() { + let ctx = egui::Context::default(); + + let handle1 = MessageBanner::set_global(&ctx, "Banner 1", MessageType::Error); + let handle2 = MessageBanner::set_global(&ctx, "Banner 2", MessageType::Error); + let _handle3 = MessageBanner::set_global(&ctx, "Banner 3", MessageType::Error); + let _handle4 = MessageBanner::set_global(&ctx, "Banner 4", MessageType::Error); + let _handle5 = MessageBanner::set_global(&ctx, "Banner 5", MessageType::Error); + + // All 5 should be alive + assert!(handle1.elapsed().is_some()); + assert!(handle2.elapsed().is_some()); + + // Adding a 6th should evict the oldest (Banner 1) + let handle6 = MessageBanner::set_global(&ctx, "Banner 6", MessageType::Error); + assert!(handle1.elapsed().is_none()); // evicted + assert!(handle2.elapsed().is_some()); // still alive + assert!(handle6.elapsed().is_some()); // newly added + + // Adding a 7th should evict Banner 2 + let _handle7 = MessageBanner::set_global(&ctx, "Banner 7", MessageType::Error); + assert!(handle2.elapsed().is_none()); // evicted +} + +/// Test that replace_global resets show_elapsed flag. +#[test] +fn test_replace_global_resets_show_elapsed() { + let ctx = egui::Context::default(); + + let handle = MessageBanner::set_global(&ctx, "Loading...", MessageType::Info); + handle.with_elapsed(); + + // Replace should reset show_elapsed and set fresh auto_dismiss + let replaced = MessageBanner::replace_global(&ctx, "Loading...", "Done!", MessageType::Success); + assert!(replaced.elapsed().is_some()); + + // The replaced banner should have auto-dismiss (Success type default), + // not the elapsed mode from the original + // We verify by checking it's still alive (just created, within 5s window) + assert!(replaced.elapsed().unwrap() < Duration::from_secs(1)); +} From 343f411500124b33278b89af3c8204ecffa0d4f0 Mon Sep 17 00:00:00 2001 From: Pasta Lil Claw Date: Wed, 18 Feb 2026 04:40:09 -0600 Subject: [PATCH 06/19] refactor(context): replace RwLock with ArcSwap (#600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(context): replace RwLock with ArcSwap Sdk is internally thread-safe (Arc, ArcSwapOption, atomics) and all methods take &self. The RwLock was adding unnecessary contention across backend tasks. Using ArcSwap instead of plain Sdk because reinit_core_client_and_sdk() needs to atomically swap the entire Sdk instance when config changes. ArcSwap provides lock-free reads with atomic swap for the rare write. Suggested-by: lklimek * fix: address CodeRabbit review findings for ArcSwap migration - Fix import ordering: move arc_swap::ArcSwap before crossbeam_channel - Remove redundant SDK loads in load_identity_from_wallet, register_dpns_name, and load_identity — use the sdk parameter already passed to these functions - Fix stale TODO referencing removed sdk.read().unwrap() API - Rename sdk_guard → sdk in transfer, withdraw_from_identity, and refresh_loaded_identities_dpns_names (no longer lock guards) - Pass &sdk to run_platform_info_task from dispatch site instead of reloading internally - Fix leftover sdk.write() call in context_provider.rs (RwLock remnant) - Add missing Color32 import in wallets dialogs Co-Authored-By: Claude Opus 4.6 * refactor: address remaining CodeRabbit review feedback on ArcSwap migration - Move SDK load outside for loop in refresh_loaded_identities_dpns_names.rs so it's loaded once for the batch instead of on each iteration - Update stale TODO comment in default_platform_version() to reflect that this is a free function with no sdk access Co-Authored-By: Claude Opus 4.6 * refactor: consolidate double read-lock on spv_context_provider Clone the SPV provider in a single lock acquisition, then bind app context on the clone instead of locking twice. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: PastaClaw Co-authored-by: Claude Opus 4.6 --- Cargo.lock | 1 + Cargo.toml | 1 + .../identity/discover_identities.rs | 2 +- src/backend_task/identity/load_identity.rs | 7 +-- .../identity/load_identity_from_wallet.rs | 7 +-- .../refresh_loaded_identities_dpns_names.rs | 9 ++-- .../identity/register_dpns_name.rs | 9 +--- .../identity/register_identity.rs | 10 +--- src/backend_task/identity/top_up_identity.rs | 5 +- src/backend_task/identity/transfer.rs | 7 +-- .../identity/withdraw_from_identity.rs | 9 ++-- src/backend_task/mod.rs | 7 +-- src/backend_task/platform_info.rs | 23 ++++---- .../wallet/fetch_platform_address_balances.rs | 5 +- .../fund_platform_address_from_asset_lock.rs | 2 +- ...fund_platform_address_from_wallet_utxos.rs | 2 +- .../wallet/transfer_platform_credits.rs | 2 +- .../wallet/withdraw_from_platform_address.rs | 2 +- src/context/connection_status.rs | 3 +- src/context/mod.rs | 54 +++++-------------- src/context/wallet_lifecycle.rs | 5 +- src/context_provider.rs | 6 +-- src/ui/wallets/wallets_screen/dialogs.rs | 2 +- 23 files changed, 53 insertions(+), 127 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d1a8f4e41..1f4117fd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1800,6 +1800,7 @@ version = "1.0.0-dev" dependencies = [ "aes-gcm", "arboard", + "arc-swap", "argon2", "base64 0.22.1", "bincode 2.0.1", diff --git a/Cargo.toml b/Cargo.toml index 8f2313ae1..dfffb323d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,7 @@ humantime = "2.2.0" which = { version = "8.0.0" } tz-rs = { version = "0.7.0" } tempfile = "3.20.0" +arc-swap = "1" [target.'cfg(not(target_os = "windows"))'.dependencies] zmq = "0.10.0" diff --git a/src/backend_task/identity/discover_identities.rs b/src/backend_task/identity/discover_identities.rs index 9d77af0eb..c16b61e09 100644 --- a/src/backend_task/identity/discover_identities.rs +++ b/src/backend_task/identity/discover_identities.rs @@ -18,7 +18,7 @@ impl AppContext { const AUTH_KEY_LOOKUP_WINDOW: u32 = 12; - let sdk = self.sdk.read().map_err(|e| e.to_string())?.clone(); + let sdk = self.sdk.load().as_ref().clone(); let seed_hash = wallet.read().map_err(|e| e.to_string())?.seed_hash(); tracing::info!( diff --git a/src/backend_task/identity/load_identity.rs b/src/backend_task/identity/load_identity.rs index c5b332f37..08038cf80 100644 --- a/src/backend_task/identity/load_identity.rs +++ b/src/backend_task/identity/load_identity.rs @@ -285,12 +285,7 @@ impl AppContext { start: None, }; - let sdk_guard = { - let guard = self.sdk.read().unwrap(); - guard.clone() - }; - - let maybe_owned_dpns_names = Document::fetch_many(&sdk_guard, dpns_names_document_query) + let maybe_owned_dpns_names = Document::fetch_many(sdk, dpns_names_document_query) .await .map(|document_map| { document_map diff --git a/src/backend_task/identity/load_identity_from_wallet.rs b/src/backend_task/identity/load_identity_from_wallet.rs index 8ccc3ba4c..8f51fb0a3 100644 --- a/src/backend_task/identity/load_identity_from_wallet.rs +++ b/src/backend_task/identity/load_identity_from_wallet.rs @@ -115,12 +115,7 @@ impl AppContext { start: None, }; - let sdk_guard = { - let guard = self.sdk.read().unwrap(); - guard.clone() - }; - - let maybe_owned_dpns_names = Document::fetch_many(&sdk_guard, dpns_names_document_query) + let maybe_owned_dpns_names = Document::fetch_many(sdk, dpns_names_document_query) .await .map(|document_map| { document_map diff --git a/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs b/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs index 2f480d085..a017e2541 100644 --- a/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs +++ b/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs @@ -17,6 +17,8 @@ impl AppContext { .load_local_qualified_identities() .map_err(|e| format!("Error refreshing owned DPNS names: Database error: {}", e))?; + let sdk = self.sdk.load().as_ref().clone(); + for mut qualified_identity in qualified_identities { let identity_id = qualified_identity.identity.id(); @@ -34,12 +36,7 @@ impl AppContext { start: None, }; - let sdk_guard = { - let guard = self.sdk.read().unwrap(); - guard.clone() - }; - - let owned_dpns_names = Document::fetch_many(&sdk_guard, dpns_names_document_query) + let owned_dpns_names = Document::fetch_many(&sdk, dpns_names_document_query) .await .map(|document_map| { document_map diff --git a/src/backend_task/identity/register_dpns_name.rs b/src/backend_task/identity/register_dpns_name.rs index 1520aef09..baee1f9c1 100644 --- a/src/backend_task/identity/register_dpns_name.rs +++ b/src/backend_task/identity/register_dpns_name.rs @@ -178,12 +178,7 @@ impl AppContext { start: None, }; - let sdk_guard = { - let guard = self.sdk.read().unwrap(); - guard.clone() - }; - - let owned_dpns_names = Document::fetch_many(&sdk_guard, dpns_names_document_query) + let owned_dpns_names = Document::fetch_many(sdk, dpns_names_document_query) .await .map(|document_map| { document_map @@ -222,7 +217,7 @@ impl AppContext { // Calculate actual fee paid // Note: We need to re-fetch the identity to get the updated balance let refreshed_identity = dash_sdk::platform::Identity::fetch_by_identifier( - &sdk_guard, + sdk, qualified_identity.identity.id(), ) .await diff --git a/src/backend_task/identity/register_identity.rs b/src/backend_task/identity/register_identity.rs index 47ae6fa5d..8b48db79a 100644 --- a/src/backend_task/identity/register_identity.rs +++ b/src/backend_task/identity/register_identity.rs @@ -36,10 +36,7 @@ impl AppContext { identity_funding_method, } = input; - let sdk = { - let guard = self.sdk.read().unwrap(); - guard.clone() - }; + let sdk = self.sdk.load().as_ref().clone(); let (_, metadata) = ExtendedEpochInfo::fetch_with_metadata(&sdk, 0, None) .await @@ -646,10 +643,7 @@ impl AppContext { ) -> Result { use dash_sdk::platform::transition::put_identity::PutIdentity; - let sdk = { - let guard = self.sdk.read().unwrap(); - guard.clone() - }; + let sdk = self.sdk.load().as_ref().clone(); let public_keys = keys.to_public_keys_map(); diff --git a/src/backend_task/identity/top_up_identity.rs b/src/backend_task/identity/top_up_identity.rs index 76d115a26..93c89c461 100644 --- a/src/backend_task/identity/top_up_identity.rs +++ b/src/backend_task/identity/top_up_identity.rs @@ -28,10 +28,7 @@ impl AppContext { identity_funding_method, } = input; - let sdk = { - let guard = self.sdk.read().unwrap(); - guard.clone() - }; + let sdk = self.sdk.load().as_ref().clone(); let (_, metadata) = ExtendedEpochInfo::fetch_with_metadata(&sdk, 0, None) .await diff --git a/src/backend_task/identity/transfer.rs b/src/backend_task/identity/transfer.rs index 84c58af0b..78e39b9d4 100644 --- a/src/backend_task/identity/transfer.rs +++ b/src/backend_task/identity/transfer.rs @@ -18,10 +18,7 @@ impl AppContext { credits: Credits, id: Option, ) -> Result { - let sdk_guard = { - let guard = self.sdk.read().unwrap(); - guard.clone() - }; + let sdk = self.sdk.load().as_ref().clone(); // Track balance before transfer for fee calculation let balance_before = qualified_identity.identity.balance(); @@ -31,7 +28,7 @@ impl AppContext { .identity .clone() .transfer_credits( - &sdk_guard, + &sdk, to_identifier, credits, id.and_then(|key_id| qualified_identity.identity.get_public_key_by_id(key_id)), diff --git a/src/backend_task/identity/withdraw_from_identity.rs b/src/backend_task/identity/withdraw_from_identity.rs index 1370e3ae5..871f4fe77 100644 --- a/src/backend_task/identity/withdraw_from_identity.rs +++ b/src/backend_task/identity/withdraw_from_identity.rs @@ -21,10 +21,7 @@ impl AppContext { credits: Credits, id: Option, ) -> Result { - let sdk_guard = { - let guard = self.sdk.read().unwrap(); - guard.clone() - }; + let sdk = self.sdk.load().as_ref().clone(); // First, refresh the identity from Platform to get the latest revision and balance tracing::info!( @@ -34,7 +31,7 @@ impl AppContext { ); let refreshed_identity = - Identity::fetch_by_identifier(&sdk_guard, qualified_identity.identity.id()) + Identity::fetch_by_identifier(&sdk, qualified_identity.identity.id()) .await .map_err(|e| format!("Failed to fetch identity from Platform: {}", e))? .ok_or_else(|| "Identity not found on Platform".to_string())?; @@ -89,7 +86,7 @@ impl AppContext { .identity .clone() .withdraw( - &sdk_guard, + &sdk, to_address, credits, Some(1), diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 7d8413fad..656c0d2a9 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -311,10 +311,7 @@ impl AppContext { task: BackendTask, sender: SenderAsync, ) -> Result { - let sdk = { - let guard = self.sdk.read().unwrap(); - guard.clone() - }; + let sdk = self.sdk.load().as_ref().clone(); match task { BackendTask::ContractTask(contract_task) => { self.run_contract_task(*contract_task, &sdk, sender).await @@ -345,7 +342,7 @@ impl AppContext { mnlist::run_mnlist_task(self, mnlist_task).await } BackendTask::PlatformInfo(platform_info_task) => { - self.run_platform_info_task(platform_info_task).await + self.run_platform_info_task(platform_info_task, &sdk).await } BackendTask::GroveSTARKTask(grovestark_task) => { grovestark::run_grovestark_task(grovestark_task, &sdk).await diff --git a/src/backend_task/platform_info.rs b/src/backend_task/platform_info.rs index 6cbf246f0..eed11ee2e 100644 --- a/src/backend_task/platform_info.rs +++ b/src/backend_task/platform_info.rs @@ -1,5 +1,6 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::context::AppContext; +use dash_sdk::Sdk; use dash_sdk::dashcore_rpc::RpcApi; use dash_sdk::dpp::block::extended_epoch_info::{v0::ExtendedEpochInfoV0Getters, ExtendedEpochInfo}; use dash_sdk::dpp::core_types::validator_set::v0::ValidatorSetV0Getters; @@ -332,12 +333,8 @@ impl AppContext { pub async fn run_platform_info_task( &self, request: PlatformInfoTaskRequestType, + sdk: &Sdk, ) -> Result { - let sdk = { - let sdk_guard = self.sdk.read().unwrap(); - sdk_guard.clone() - }; - match request { PlatformInfoTaskRequestType::BasicPlatformInfo => { // Get platform version from SDK @@ -365,7 +362,7 @@ impl AppContext { )) } PlatformInfoTaskRequestType::CurrentEpochInfo => { - match ExtendedEpochInfo::fetch_current(&sdk).await { + match ExtendedEpochInfo::fetch_current(sdk).await { Ok(epoch_info) => { // Cache the fee multiplier for UI fee estimation let fee_multiplier = epoch_info.fee_multiplier_permille(); @@ -385,7 +382,7 @@ impl AppContext { } } PlatformInfoTaskRequestType::TotalCreditsOnPlatform => { - match TotalCreditsInPlatform::fetch_current(&sdk).await { + match TotalCreditsInPlatform::fetch_current(sdk).await { Ok(total_credits) => { let dash_amount = total_credits.0 as f64 * 10f64.powf(-11.0); let formatted = format!( @@ -402,7 +399,7 @@ impl AppContext { } } PlatformInfoTaskRequestType::CurrentVersionVotingState => { - match ProtocolVersionVoteCount::fetch_many(&sdk, ()).await { + match ProtocolVersionVoteCount::fetch_many(sdk, ()).await { Ok(votes) => { let votes: ProtocolVersionUpgrades = votes; let votes_info = votes @@ -428,7 +425,7 @@ impl AppContext { } } PlatformInfoTaskRequestType::CurrentValidatorSetInfo => { - match CurrentQuorumsInfo::fetch_unproved(&sdk, NoParamQuery {}).await { + match CurrentQuorumsInfo::fetch_unproved(sdk, NoParamQuery {}).await { Ok(Some(current_quorums_info)) => { let formatted = format_current_quorums_info(¤t_quorums_info); Ok(BackendTaskSuccessResult::PlatformInfo( @@ -461,13 +458,13 @@ impl AppContext { start: None, }; - match Document::fetch_many(&sdk, queued_document_query.clone()).await { + match Document::fetch_many(sdk, queued_document_query.clone()).await { Ok(documents) => { let withdrawal_docs: Vec = documents.values().filter_map(|a| a.clone()).collect(); // Try to get total credits for daily limit calculation - match TotalCreditsInPlatform::fetch_current(&sdk).await { + match TotalCreditsInPlatform::fetch_current(sdk).await { Ok(total_credits) => { let formatted = format_withdrawal_documents_with_daily_limit( &withdrawal_docs, @@ -526,7 +523,7 @@ impl AppContext { start: None, }; - match Document::fetch_many(&sdk, completed_document_query).await { + match Document::fetch_many(sdk, completed_document_query).await { Ok(documents) => { let mut withdrawal_docs: Vec = documents.values().filter_map(|a| a.clone()).collect(); @@ -633,7 +630,7 @@ impl AppContext { // Fetch the address info using FetchMany with BTreeSet let mut addresses = std::collections::BTreeSet::new(); addresses.insert(platform_address); - match AddressInfo::fetch_many(&sdk, addresses).await { + match AddressInfo::fetch_many(sdk, addresses).await { Ok(address_infos) => { // The result is a map of PlatformAddress -> Option let result: Option<&Option> = diff --git a/src/backend_task/wallet/fetch_platform_address_balances.rs b/src/backend_task/wallet/fetch_platform_address_balances.rs index d07f7e030..737129fd3 100644 --- a/src/backend_task/wallet/fetch_platform_address_balances.rs +++ b/src/backend_task/wallet/fetch_platform_address_balances.rs @@ -76,10 +76,7 @@ impl AppContext { }; // Sync using SDK's privacy-preserving method - let sdk = { - let guard = self.sdk.read().map_err(|e| e.to_string())?; - guard.clone() - }; + let sdk = self.sdk.load().as_ref().clone(); let (_checkpoint_height, highest_block_processed) = if needs_full_sync { tracing::info!( diff --git a/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs b/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs index 02983677c..f189b7fc2 100644 --- a/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs +++ b/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs @@ -34,7 +34,7 @@ impl AppContext { .ok_or_else(|| "Wallet not found".to_string())? }; let wallet = wallet_arc.read().map_err(|e| e.to_string())?.clone(); - let sdk = self.sdk.read().map_err(|e| e.to_string())?.clone(); + let sdk = self.sdk.load().as_ref().clone(); // Get the private key for the asset lock address let private_key = wallet diff --git a/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs b/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs index aa67973ab..ee95378e9 100644 --- a/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs +++ b/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs @@ -203,7 +203,7 @@ impl AppContext { }; let wallet = wallet_arc.read().map_err(|e| e.to_string())?.clone(); - let sdk = self.sdk.read().map_err(|e| e.to_string())?.clone(); + let sdk = self.sdk.load().as_ref().clone(); (wallet, sdk, change_platform_address) }; diff --git a/src/backend_task/wallet/transfer_platform_credits.rs b/src/backend_task/wallet/transfer_platform_credits.rs index 3549af4be..0d580a0dd 100644 --- a/src/backend_task/wallet/transfer_platform_credits.rs +++ b/src/backend_task/wallet/transfer_platform_credits.rs @@ -28,7 +28,7 @@ impl AppContext { .ok_or_else(|| "Wallet not found".to_string())? }; let wallet = wallet_arc.read().map_err(|e| e.to_string())?.clone(); - let sdk = self.sdk.read().map_err(|e| e.to_string())?.clone(); + let sdk = self.sdk.load().as_ref().clone(); (wallet, sdk) }; diff --git a/src/backend_task/wallet/withdraw_from_platform_address.rs b/src/backend_task/wallet/withdraw_from_platform_address.rs index a12b8948f..f717943e4 100644 --- a/src/backend_task/wallet/withdraw_from_platform_address.rs +++ b/src/backend_task/wallet/withdraw_from_platform_address.rs @@ -32,7 +32,7 @@ impl AppContext { .ok_or_else(|| "Wallet not found".to_string())? }; let wallet = wallet_arc.read().map_err(|e| e.to_string())?.clone(); - let sdk = self.sdk.read().map_err(|e| e.to_string())?.clone(); + let sdk = self.sdk.load().as_ref().clone(); (wallet, sdk) }; diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index 9672804cf..882b631b7 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -330,7 +330,8 @@ impl ConnectionStatus { } // Update DAPI endpoint status - if let Ok(sdk) = app_context.sdk.read() { + { + let sdk = app_context.sdk.load(); let address_list = sdk.address_list(); let total = address_list.len() as u16; // get_live_address() returns Option<&Uri>, so count it as 1 if available, 0 if not diff --git a/src/context/mod.rs b/src/context/mod.rs index 0abf915fd..aec9533b5 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -20,6 +20,7 @@ use crate::model::wallet::{Wallet, WalletSeedHash}; use crate::sdk_wrapper::initialize_sdk; use crate::spv::{CoreBackendMode, SpvManager}; use crate::utils::tasks::TaskManager; +use arc_swap::ArcSwap; use connection_status::ConnectionStatus; use crossbeam_channel::{Receiver, Sender}; use dash_sdk::Sdk; @@ -54,7 +55,7 @@ pub struct AppContext { #[allow(dead_code)] // May be used for devnet identification pub(crate) devnet_name: Option, pub(crate) db: Arc, - pub(crate) sdk: RwLock, + pub(crate) sdk: ArcSwap, // Context providers for SDK, so we can switch when backend mode changes spv_context_provider: RwLock, rpc_context_provider: RwLock, @@ -245,7 +246,7 @@ impl AppContext { developer_mode: AtomicBool::new(developer_mode_enabled), devnet_name: None, db, - sdk: sdk.into(), + sdk: ArcSwap::from_pointee(sdk), spv_context_provider: spv_provider.into(), rpc_context_provider: rpc_provider.into(), config: config_lock, @@ -303,13 +304,6 @@ impl AppContext { } } else { // Ensure SDK uses the SPV provider - let sdk_lock = match app_context.sdk.write() { - Ok(lock) => lock, - Err(_) => { - tracing::error!("SDK lock poisoned"); - return None; - } - }; let provider = match app_context.spv_context_provider.read() { Ok(p) => p.clone(), Err(_) => { @@ -317,7 +311,7 @@ impl AppContext { return None; } }; - sdk_lock.set_context_provider(provider); + app_context.sdk.load().set_context_provider(provider); } app_context.bootstrap_loaded_wallets(); @@ -363,23 +357,7 @@ impl AppContext { // Switch SDK context provider to match the selected backend match mode { CoreBackendMode::Spv => { - // Make sure SPV provider knows about the app context - if let Err(e) = self - .spv_context_provider - .read() - .map_err(|_| "SPV provider lock poisoned".to_string()) - .and_then(|provider| provider.bind_app_context(Arc::clone(self))) - { - tracing::error!("Failed to bind SPV provider: {}", e); - return; - } - let sdk = match self.sdk.write() { - Ok(lock) => lock, - Err(_) => { - tracing::error!("SDK lock poisoned in set_core_backend_mode"); - return; - } - }; + // Clone the SPV provider and bind app context on the clone let provider = match self.spv_context_provider.read() { Ok(p) => p.clone(), Err(_) => { @@ -387,7 +365,11 @@ impl AppContext { return; } }; - sdk.set_context_provider(provider); + if let Err(e) = provider.bind_app_context(Arc::clone(self)) { + tracing::error!("Failed to bind SPV provider: {}", e); + return; + } + self.sdk.load().set_context_provider(provider); } CoreBackendMode::Rpc => { // RPC provider binding also sets itself on the SDK @@ -512,13 +494,7 @@ impl AppContext { .map_err(|_| "Core client lock poisoned".to_string())?; *client_lock = new_client; } - { - let mut sdk_lock = self - .sdk - .write() - .map_err(|_| "SDK lock poisoned".to_string())?; - *sdk_lock = new_sdk; - } + self.sdk.store(Arc::new(new_sdk)); // Rebind providers to ensure they hold the new AppContext reference self.spv_context_provider @@ -531,16 +507,12 @@ impl AppContext { .map_err(|_| "RPC provider lock poisoned".to_string())? .bind_app_context(self.clone())?; } else { - let sdk_lock = self - .sdk - .write() - .map_err(|_| "SDK lock poisoned".to_string())?; let provider = self .spv_context_provider .read() .map_err(|_| "SPV provider lock poisoned".to_string())? .clone(); - sdk_lock.set_context_provider(provider); + self.sdk.load().set_context_provider(provider); } Ok(()) @@ -549,7 +521,7 @@ impl AppContext { /// Returns the default platform version for the given network. pub(crate) const fn default_platform_version(network: &Network) -> &'static PlatformVersion { - // TODO: Use self.sdk.read().unwrap().version() instead of hardcoding + // TODO: Ideally use sdk.load().version() but this is a free function with no sdk access match network { Network::Dash => &PLATFORM_V11, Network::Testnet => &PLATFORM_V11, diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 8d1117bdd..537cb5689 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -478,10 +478,7 @@ impl AppContext { return Ok(()); } - let sdk = { - let guard = self.sdk.read().map_err(|_| "SDK lock poisoned")?; - guard.clone() - }; + let sdk = self.sdk.load().as_ref().clone(); for txid in pending_txids { match get_transaction_info(&sdk, &txid).await { diff --git a/src/context_provider.rs b/src/context_provider.rs index 65e931466..76df9c10b 100644 --- a/src/context_provider.rs +++ b/src/context_provider.rs @@ -70,11 +70,7 @@ impl Provider { ac.replace(cloned); drop(ac); - let sdk = app_context - .sdk - .write() - .map_err(|_| "SDK lock poisoned".to_string())?; - sdk.set_context_provider(self.clone()); + app_context.sdk.load().set_context_provider(self.clone()); Ok(()) } } diff --git a/src/ui/wallets/wallets_screen/dialogs.rs b/src/ui/wallets/wallets_screen/dialogs.rs index 701fff7f7..d48eebc57 100644 --- a/src/ui/wallets/wallets_screen/dialogs.rs +++ b/src/ui/wallets/wallets_screen/dialogs.rs @@ -17,7 +17,7 @@ use eframe::egui::{self, ComboBox, Context}; use eframe::epaint::TextureHandle; use egui::Color32; use egui::load::SizedTexture; -use egui::{Frame, Margin, RichText, TextureOptions}; +use egui::{Color32, Frame, Margin, RichText, TextureOptions}; use std::sync::{Arc, RwLock}; use super::WalletsBalancesScreen; From d9ddbfd696871e788bd4eb559878e3f3fd0be115 Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:07:17 +0100 Subject: [PATCH 07/19] refactor: remove unused Insight API and show_in_ui config fields (#597) * refactor: remove unused Insight API references The `insight_api_url` field in `NetworkConfig` and its associated `insight_api_uri()` method were never used in production code (both marked `#[allow(dead_code)]`). Remove the field, method, config entries, env example lines, and related tests. https://claude.ai/code/session_01HWPmCJHT8KTZGP9bFiksjn * refactor: remove unused `show_in_ui` field from NetworkConfig The `show_in_ui` field was defined on `NetworkConfig` and serialized in `save()`, but never read by any production code to control network visibility. Remove the field, its serialization, env example lines, and test references. https://claude.ai/code/session_01HWPmCJHT8KTZGP9bFiksjn * fix: add missing `Color32` import in wallet dialogs https://claude.ai/code/session_01HWPmCJHT8KTZGP9bFiksjn --------- Co-authored-by: Claude --- .env.example | 8 -- src/config.rs | 222 ++++------------------------- src/spv/tests.rs | 2 - src/ui/tokens/tokens_screen/mod.rs | 4 - 4 files changed, 27 insertions(+), 209 deletions(-) diff --git a/.env.example b/.env.example index 9e49c25c6..c1d9b0a75 100644 --- a/.env.example +++ b/.env.example @@ -7,27 +7,21 @@ MAINNET_core_host=127.0.0.1 MAINNET_core_rpc_port=9998 MAINNET_core_rpc_user=dashrpc MAINNET_core_rpc_password=password -MAINNET_insight_api_url=https://insight.dash.org/insight-api MAINNET_core_zmq_endpoint=tcp://127.0.0.1:23708 -MAINNET_show_in_ui=true TESTNET_dapi_addresses=https://34.214.48.68:1443,https://52.12.176.90:1443,https://52.34.144.50:1443,https://44.240.98.102:1443,https://54.201.32.131:1443,https://52.10.229.11:1443,https://52.13.132.146:1443,https://52.40.219.41:1443,https://54.149.33.167:1443,https://35.164.23.245:1443,https://52.33.28.47:1443,https://52.43.13.92:1443,https://52.89.154.48:1443,https://52.24.124.162:1443,https://35.85.21.179:1443,https://54.187.14.232:1443,https://54.68.235.201:1443,https://52.13.250.182:1443 TESTNET_core_host=127.0.0.1 TESTNET_core_rpc_port=19998 TESTNET_core_rpc_user=dashrpc TESTNET_core_rpc_password=password -TESTNET_insight_api_url=https://testnet-insight.dash.org/insight-api TESTNET_core_zmq_endpoint=tcp://127.0.0.1:23709 -TESTNET_show_in_ui=true DEVNET_dapi_addresses=http://54.203.116.91:1443,http://52.88.16.46:1443,http://34.222.214.170:1443,http://54.214.77.108:1443,http://52.40.186.234:1443,http://54.202.231.20:1443,http://35.89.246.86:1443,http://18.246.227.21:1443,http://44.245.96.164:1443,http://44.247.160.52:1443 DEVNET_core_host=127.0.0.1 DEVNET_core_rpc_port=29998 DEVNET_core_rpc_user=dashrpc DEVNET_core_rpc_password=password -DEVNET_insight_api_url= DEVNET_core_zmq_endpoint=tcp://127.0.0.1:23710 -DEVNET_show_in_ui=true LOCAL_dapi_addresses=http://127.0.0.1:2443,http://127.0.0.1:2543,http://127.0.0.1:2643 LOCAL_core_host=127.0.0.1 @@ -36,6 +30,4 @@ LOCAL_core_rpc_user=dashmate # Use dashmate cli to retrive it: # dashmate config get core.rpc.users.dashmate.password --config=local_seed LOCAL_core_rpc_password=password -LOCAL_insight_api_url=http://localhost:3001/insight-api LOCAL_core_zmq_endpoint=tcp://127.0.0.1:50298 -LOCAL_show_in_ui=true \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index 7d829acc7..287fb4a73 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,7 +4,6 @@ use std::str::FromStr; use crate::app_dir::app_user_data_file_path; use dash_sdk::dapi_client::AddressList; use dash_sdk::dpp::dashcore::Network; -use dash_sdk::sdk::Uri; use serde::Deserialize; use tempfile::NamedTempFile; @@ -38,16 +37,12 @@ pub struct NetworkConfig { pub core_rpc_user: String, /// Password for Dash Core RPC interface pub core_rpc_password: String, - /// URL of the Insight API - pub insight_api_url: String, /// ZMQ endpoint for Core blockchain events (e.g., tcp://127.0.0.1:23708) pub core_zmq_endpoint: Option, /// Devnet network name if one exists pub devnet_name: Option, /// Optional wallet private key to instantiate the wallet pub wallet_private_key: Option, - /// Should this network be visible in the UI - pub show_in_ui: bool, } impl Config { @@ -108,13 +103,6 @@ impl Config { prefix, config.core_rpc_password ) .map_err(|e| ConfigError::LoadError(e.to_string()))?; - writeln!( - env_file, - "{}insight_api_url={}", - prefix, config.insight_api_url - ) - .map_err(|e| ConfigError::LoadError(e.to_string()))?; - if let Some(core_zmq_endpoint) = &config.core_zmq_endpoint { writeln!( env_file, @@ -138,10 +126,6 @@ impl Config { .map_err(|e| ConfigError::LoadError(e.to_string()))?; } - // Whether or not to show in UI - writeln!(env_file, "{}show_in_ui={}", prefix, config.show_in_ui) - .map_err(|e| ConfigError::LoadError(e.to_string()))?; - // Add a blank line after each config block writeln!(env_file).map_err(|e| ConfigError::LoadError(e.to_string()))?; @@ -315,7 +299,6 @@ impl NetworkConfig { && !self.core_rpc_password.is_empty() && self.core_rpc_port != 0 && !self.dapi_addresses.is_empty() - && Uri::from_str(&self.insight_api_url).is_ok() } /// List of DAPI addresses @@ -323,12 +306,6 @@ impl NetworkConfig { AddressList::from_str(&self.dapi_addresses).expect("Could not parse DAPI addresses") } - /// Insight API URI - #[allow(dead_code)] // May be used for insight API access - pub fn insight_api_uri(&self) -> Uri { - Uri::from_str(&self.insight_api_url).expect("invalid insight API URL") - } - /// Update just the `core_rpc_password` in a builder-like manner. /// Returns a new `NetworkConfig` with the updated password. pub fn update_core_rpc_password(mut self, new_password: String) -> Self { @@ -342,22 +319,16 @@ mod tests { use super::*; /// Helper to create a minimal valid NetworkConfig for testing - fn make_network_config( - dapi_addresses: &str, - insight_api_url: &str, - port: u16, - ) -> NetworkConfig { + fn make_network_config(dapi_addresses: &str, port: u16) -> NetworkConfig { NetworkConfig { dapi_addresses: dapi_addresses.to_string(), core_host: "127.0.0.1".to_string(), core_rpc_port: port, core_rpc_user: "dashrpc".to_string(), core_rpc_password: "password".to_string(), - insight_api_url: insight_api_url.to_string(), core_zmq_endpoint: Some("tcp://127.0.0.1:23708".to_string()), devnet_name: None, wallet_private_key: None, - show_in_ui: true, } } @@ -365,55 +336,33 @@ mod tests { #[test] fn test_is_valid_with_all_fields() { - let config = make_network_config( - "https://127.0.0.1:443", - "https://insight.dash.org/insight-api", - 9998, - ); + let config = make_network_config("https://127.0.0.1:443", 9998); assert!(config.is_valid()); } #[test] fn test_is_valid_empty_user() { - let mut config = make_network_config( - "https://127.0.0.1:443", - "https://insight.dash.org/insight-api", - 9998, - ); + let mut config = make_network_config("https://127.0.0.1:443", 9998); config.core_rpc_user = String::new(); assert!(!config.is_valid()); } #[test] fn test_is_valid_empty_password() { - let mut config = make_network_config( - "https://127.0.0.1:443", - "https://insight.dash.org/insight-api", - 9998, - ); + let mut config = make_network_config("https://127.0.0.1:443", 9998); config.core_rpc_password = String::new(); assert!(!config.is_valid()); } #[test] fn test_is_valid_zero_port() { - let config = make_network_config( - "https://127.0.0.1:443", - "https://insight.dash.org/insight-api", - 0, - ); + let config = make_network_config("https://127.0.0.1:443", 0); assert!(!config.is_valid()); } #[test] fn test_is_valid_empty_dapi_addresses() { - let config = make_network_config("", "https://insight.dash.org/insight-api", 9998); - assert!(!config.is_valid()); - } - - #[test] - fn test_is_valid_invalid_insight_url() { - let config = make_network_config("https://127.0.0.1:443", "not a valid url \x00", 9998); + let config = make_network_config("", 9998); assert!(!config.is_valid()); } @@ -421,11 +370,7 @@ mod tests { #[test] fn test_dapi_address_list_single_address() { - let config = make_network_config( - "https://127.0.0.1:443", - "https://insight.dash.org/insight-api", - 9998, - ); + let config = make_network_config("https://127.0.0.1:443", 9998); let list = config.dapi_address_list(); assert_eq!(list.len(), 1); } @@ -434,7 +379,6 @@ mod tests { fn test_dapi_address_list_multiple_addresses() { let config = make_network_config( "https://127.0.0.1:443,https://192.168.1.1:443,https://10.0.0.1:443", - "https://insight.dash.org/insight-api", 9998, ); let list = config.dapi_address_list(); @@ -444,46 +388,15 @@ mod tests { #[test] #[should_panic(expected = "Could not parse DAPI addresses")] fn test_dapi_address_list_empty_panics() { - let config = make_network_config("", "https://insight.dash.org/insight-api", 9998); + let config = make_network_config("", 9998); let _list = config.dapi_address_list(); } - // ── NetworkConfig::insight_api_uri ─────────────────────────────── - - #[test] - fn test_insight_api_uri_valid() { - let config = make_network_config( - "https://127.0.0.1:443", - "https://insight.dash.org/insight-api", - 9998, - ); - let uri = config.insight_api_uri(); - assert_eq!(uri.to_string(), "https://insight.dash.org/insight-api"); - } - - #[test] - #[should_panic(expected = "invalid insight API URL")] - fn test_insight_api_uri_invalid_panics() { - let config = make_network_config("https://127.0.0.1:443", "not a valid url \x00", 9998); - let _uri = config.insight_api_uri(); - } - - #[test] - #[should_panic(expected = "invalid insight API URL")] - fn test_insight_api_uri_empty_panics() { - let config = make_network_config("https://127.0.0.1:443", "", 9998); - let _uri = config.insight_api_uri(); - } - // ── NetworkConfig::update_core_rpc_password ───────────────────── #[test] fn test_update_core_rpc_password() { - let config = make_network_config( - "https://127.0.0.1:443", - "https://insight.dash.org/insight-api", - 9998, - ); + let config = make_network_config("https://127.0.0.1:443", 9998); assert_eq!(config.core_rpc_password, "password"); let updated = config.update_core_rpc_password("new_secret".to_string()); assert_eq!(updated.core_rpc_password, "new_secret"); @@ -496,11 +409,7 @@ mod tests { #[test] fn test_config_for_network_mainnet() { - let mainnet_cfg = make_network_config( - "https://127.0.0.1:443", - "https://insight.dash.org/insight-api", - 9998, - ); + let mainnet_cfg = make_network_config("https://127.0.0.1:443", 9998); let config = Config { mainnet_config: Some(mainnet_cfg), testnet_config: None, @@ -517,26 +426,10 @@ mod tests { #[test] fn test_config_for_network_all_networks() { let config = Config { - mainnet_config: Some(make_network_config( - "https://1.1.1.1:443", - "https://main.example.com", - 9998, - )), - testnet_config: Some(make_network_config( - "https://2.2.2.2:1443", - "https://test.example.com", - 19998, - )), - devnet_config: Some(make_network_config( - "http://3.3.3.3:1443", - "http://dev.example.com", - 29998, - )), - local_config: Some(make_network_config( - "http://127.0.0.1:2443", - "http://localhost:3001", - 20302, - )), + mainnet_config: Some(make_network_config("https://1.1.1.1:443", 9998)), + testnet_config: Some(make_network_config("https://2.2.2.2:1443", 19998)), + devnet_config: Some(make_network_config("http://3.3.3.3:1443", 29998)), + local_config: Some(make_network_config("http://127.0.0.1:2443", 20302)), developer_mode: Some(true), }; let main = config.config_for_network(Network::Dash).as_ref().unwrap(); @@ -567,11 +460,7 @@ mod tests { developer_mode: None, }; assert!(config.mainnet_config.is_none()); - let new_cfg = make_network_config( - "https://1.1.1.1:443", - "https://insight.dash.org/insight-api", - 9998, - ); + let new_cfg = make_network_config("https://1.1.1.1:443", 9998); config.update_config_for_network(Network::Dash, new_cfg); assert!(config.mainnet_config.is_some()); assert_eq!(config.mainnet_config.as_ref().unwrap().core_rpc_port, 9998); @@ -580,21 +469,13 @@ mod tests { #[test] fn test_update_config_replaces_existing() { let mut config = Config { - mainnet_config: Some(make_network_config( - "https://old.example.com:443", - "https://old.example.com", - 1111, - )), + mainnet_config: Some(make_network_config("https://old.example.com:443", 1111)), testnet_config: None, devnet_config: None, local_config: None, developer_mode: None, }; - let new_cfg = make_network_config( - "https://new.example.com:443", - "https://new.example.com", - 2222, - ); + let new_cfg = make_network_config("https://new.example.com:443", 2222); config.update_config_for_network(Network::Dash, new_cfg); let main = config.mainnet_config.as_ref().unwrap(); assert_eq!(main.core_rpc_port, 2222); @@ -612,15 +493,15 @@ mod tests { }; config.update_config_for_network( Network::Testnet, - make_network_config("https://t.example.com:1443", "https://t.example.com", 19998), + make_network_config("https://t.example.com:1443", 19998), ); config.update_config_for_network( Network::Devnet, - make_network_config("http://d.example.com:1443", "http://d.example.com", 29998), + make_network_config("http://d.example.com:1443", 29998), ); config.update_config_for_network( Network::Regtest, - make_network_config("http://127.0.0.1:2443", "http://localhost:3001", 20302), + make_network_config("http://127.0.0.1:2443", 20302), ); assert!(config.testnet_config.is_some()); assert!(config.devnet_config.is_some()); @@ -631,11 +512,7 @@ mod tests { #[test] fn test_network_config_optional_fields() { - let mut config = make_network_config( - "https://127.0.0.1:443", - "https://insight.dash.org/insight-api", - 9998, - ); + let mut config = make_network_config("https://127.0.0.1:443", 9998); // Defaults assert!(config.devnet_name.is_none()); assert!(config.wallet_private_key.is_none()); @@ -658,16 +535,8 @@ mod tests { // We can't easily test save() directly since it depends on app_user_data_file_path, // but we can verify the save format by manually constructing what save() would write. let config = Config { - mainnet_config: Some(make_network_config( - "https://1.1.1.1:443", - "https://insight.dash.org/insight-api", - 9998, - )), - testnet_config: Some(make_network_config( - "https://2.2.2.2:1443", - "https://testnet-insight.dash.org/insight-api", - 19998, - )), + mainnet_config: Some(make_network_config("https://1.1.1.1:443", 9998)), + testnet_config: Some(make_network_config("https://2.2.2.2:1443", 19998)), devnet_config: None, local_config: None, developer_mode: Some(true), @@ -684,23 +553,16 @@ mod tests { "MAINNET_core_rpc_password={}\n", cfg.core_rpc_password )); - output.push_str(&format!( - "MAINNET_insight_api_url={}\n", - cfg.insight_api_url - )); if let Some(ref zmq) = cfg.core_zmq_endpoint { output.push_str(&format!("MAINNET_core_zmq_endpoint={}\n", zmq)); } - output.push_str(&format!("MAINNET_show_in_ui={}\n", cfg.show_in_ui)); } assert!(output.contains("MAINNET_dapi_addresses=https://1.1.1.1:443")); assert!(output.contains("MAINNET_core_rpc_port=9998")); assert!(output.contains("MAINNET_core_rpc_user=dashrpc")); assert!(output.contains("MAINNET_core_rpc_password=password")); - assert!(output.contains("MAINNET_insight_api_url=https://insight.dash.org/insight-api")); assert!(output.contains("MAINNET_core_zmq_endpoint=tcp://127.0.0.1:23708")); - assert!(output.contains("MAINNET_show_in_ui=true")); } // ── envy parsing roundtrip ────────────────────────────────────── @@ -720,15 +582,10 @@ mod tests { env_map.insert("TEST_RT_core_rpc_port".into(), "9998".into()); env_map.insert("TEST_RT_core_rpc_user".into(), "testuser".into()); env_map.insert("TEST_RT_core_rpc_password".into(), "testpass".into()); - env_map.insert( - "TEST_RT_insight_api_url".into(), - "https://insight.example.com/api".into(), - ); env_map.insert( "TEST_RT_core_zmq_endpoint".into(), "tcp://127.0.0.1:29999".into(), ); - env_map.insert("TEST_RT_show_in_ui".into(), "true".into()); // Use envy's from_iter to parse from our map (same as from_env but testable) let result: Result = envy::prefixed("TEST_RT_") @@ -740,12 +597,10 @@ mod tests { assert_eq!(config.core_rpc_port, 9998); assert_eq!(config.core_rpc_user, "testuser"); assert_eq!(config.core_rpc_password, "testpass"); - assert_eq!(config.insight_api_url, "https://insight.example.com/api"); assert_eq!( config.core_zmq_endpoint, Some("tcp://127.0.0.1:29999".to_string()) ); - assert!(config.show_in_ui); assert!(config.devnet_name.is_none()); assert!(config.wallet_private_key.is_none()); } @@ -760,8 +615,6 @@ mod tests { env_map.insert("OPT_core_rpc_port".into(), "29998".into()); env_map.insert("OPT_core_rpc_user".into(), "user".into()); env_map.insert("OPT_core_rpc_password".into(), "pass".into()); - env_map.insert("OPT_insight_api_url".into(), "http://localhost:3001".into()); - env_map.insert("OPT_show_in_ui".into(), "false".into()); env_map.insert("OPT_devnet_name".into(), "devnet-evo".into()); env_map.insert("OPT_wallet_private_key".into(), "cVBZ1234abcd".into()); @@ -771,7 +624,6 @@ mod tests { let config = result.unwrap(); assert_eq!(config.devnet_name, Some("devnet-evo".to_string())); assert_eq!(config.wallet_private_key, Some("cVBZ1234abcd".to_string())); - assert!(!config.show_in_ui); assert!(config.core_zmq_endpoint.is_none()); } @@ -786,8 +638,6 @@ mod tests { // core_rpc_port intentionally missing env_map.insert("MISS_core_rpc_user".into(), "user".into()); env_map.insert("MISS_core_rpc_password".into(), "pass".into()); - env_map.insert("MISS_insight_api_url".into(), "http://localhost".into()); - env_map.insert("MISS_show_in_ui".into(), "true".into()); let result: Result = envy::prefixed("MISS_").from_iter(env_map.iter().map(|(k, v)| (k.clone(), v.clone()))); @@ -804,8 +654,6 @@ mod tests { env_map.insert("BAD_core_rpc_port".into(), "not_a_number".into()); env_map.insert("BAD_core_rpc_user".into(), "user".into()); env_map.insert("BAD_core_rpc_password".into(), "pass".into()); - env_map.insert("BAD_insight_api_url".into(), "http://localhost".into()); - env_map.insert("BAD_show_in_ui".into(), "true".into()); let result: Result = envy::prefixed("BAD_").from_iter(env_map.iter().map(|(k, v)| (k.clone(), v.clone()))); @@ -817,11 +665,7 @@ mod tests { #[test] fn test_config_developer_mode() { let config = Config { - mainnet_config: Some(make_network_config( - "https://1.1.1.1:443", - "https://insight.dash.org", - 9998, - )), + mainnet_config: Some(make_network_config("https://1.1.1.1:443", 9998)), testnet_config: None, devnet_config: None, local_config: None, @@ -830,11 +674,7 @@ mod tests { assert_eq!(config.developer_mode, Some(true)); let config_off = Config { - mainnet_config: Some(make_network_config( - "https://1.1.1.1:443", - "https://insight.dash.org", - 9998, - )), + mainnet_config: Some(make_network_config("https://1.1.1.1:443", 9998)), testnet_config: None, devnet_config: None, local_config: None, @@ -843,11 +683,7 @@ mod tests { assert_eq!(config_off.developer_mode, Some(false)); let config_none = Config { - mainnet_config: Some(make_network_config( - "https://1.1.1.1:443", - "https://insight.dash.org", - 9998, - )), + mainnet_config: Some(make_network_config("https://1.1.1.1:443", 9998)), testnet_config: None, devnet_config: None, local_config: None, @@ -861,11 +697,7 @@ mod tests { #[test] fn test_config_clone() { let config = Config { - mainnet_config: Some(make_network_config( - "https://1.1.1.1:443", - "https://insight.dash.org", - 9998, - )), + mainnet_config: Some(make_network_config("https://1.1.1.1:443", 9998)), testnet_config: None, devnet_config: None, local_config: None, diff --git a/src/spv/tests.rs b/src/spv/tests.rs index 776a756e0..823a4c45c 100644 --- a/src/spv/tests.rs +++ b/src/spv/tests.rs @@ -20,11 +20,9 @@ fn test_network_config() -> NetworkConfig { core_rpc_port: 19998, core_rpc_user: "dashrpc".to_string(), core_rpc_password: "password".to_string(), - insight_api_url: "https://testnet-insight.dash.org/insight-api".to_string(), core_zmq_endpoint: Some("tcp://127.0.0.1:23709".to_string()), devnet_name: None, wallet_private_key: None, - show_in_ui: true, } } diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index 5eee677c7..4c5f737a9 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -3201,16 +3201,12 @@ mod tests { std::env::set_var("MAINNET_core_rpc_port", "9998"); std::env::set_var("MAINNET_core_rpc_user", "dashrpc"); std::env::set_var("MAINNET_core_rpc_password", "password"); - std::env::set_var("MAINNET_insight_api_url", "http://127.0.0.1:3001"); - std::env::set_var("MAINNET_show_in_ui", "true"); std::env::set_var("LOCAL_dapi_addresses", "http://127.0.0.1:2443"); std::env::set_var("LOCAL_core_host", "127.0.0.1"); std::env::set_var("LOCAL_core_rpc_port", "20302"); std::env::set_var("LOCAL_core_rpc_user", "dashmate"); std::env::set_var("LOCAL_core_rpc_password", "password"); - std::env::set_var("LOCAL_insight_api_url", "http://127.0.0.1:3001"); - std::env::set_var("LOCAL_show_in_ui", "true"); } }); } From b7bc9680e6fa13a5ec02f44f2387523ca8e4067b Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Wed, 18 Feb 2026 18:17:43 +0100 Subject: [PATCH 08/19] build: add Flatpak packaging and CI workflow (#589) * build: remove snap version * build: add Flatpak packaging and CI workflow Add Flatpak build manifest, desktop entry, AppStream metadata, and GitHub Actions workflow for building and distributing Flatpak bundles. Uses freedesktop 25.08 runtime with rust-stable and llvm21 extensions. No application source code changes required - works in SPV mode by default. Co-Authored-By: Claude Opus 4.6 * build: address review findings for Flatpak packaging - Pin GitHub Actions to commit SHAs for supply chain security - Upgrade softprops/action-gh-release from v1 to v2.2.2 - Remove redundant --socket=x11 (fallback-x11 suffices) - Remove duplicate tag trigger preventing double builds on release - Remove duplicate env vars inherited from top-level build-options - Add Flatpak build artifacts to .gitignore - Add bugtracker URL to AppStream metainfo - Remove deprecated from metainfo (use .desktop instead) - Add Terminal=false and Keywords to desktop entry - Add disk space check after SDK install in CI - Rename artifact to include architecture suffix Co-Authored-By: Claude Opus 4.6 * build: simplify CI workflows for Linux-only releases - Remove "Free disk space" step from flatpak and release workflows - Remove Windows and macOS builds from release workflow - Use "ubuntu" runner image instead of pinned versions - Clean up unused matrix.ext references Co-Authored-By: Claude Opus 4.6 * build: attach to existing releases instead of creating new ones - Replace release-creating job with attach-to-release (only on release event) - Add 14-day retention for build artifacts - On tag push or workflow_dispatch, only upload artifacts (no release) Co-Authored-By: Claude Opus 4.6 * revert: restore release.yml to original v1.0-dev version The release workflow changes were out of scope for the Flatpak PR. Co-Authored-By: Claude Opus 4.6 * fix: address CodeRabbit review comments - Fix CRLF line endings in Flatpak manifest (convert to LF) - Set app_id on ViewportBuilder to match desktop StartupWMClass - Use --locked flag for reproducible cargo builds in Flatpak - Rename --repo=repo to --repo=flatpak-repo to match .gitignore - Add architecture note for protoc x86_64 binary Co-Authored-By: Claude Opus 4.6 * docs: add Flatpak install instructions to README Add a dedicated section for installing via Flatpak on Linux, clarify that prerequisites are only needed for building from source, and rename "Installation" to "Build from Source" for clarity. Co-Authored-By: Claude Opus 4.6 * fix: match StartupWMClass to Flatpak app_id Use reverse-DNS format org.dash.DashEvoTool to match the Wayland app_id set via ViewportBuilder::with_app_id(). Co-Authored-By: Claude Opus 4.6 * fix: use ** glob for branch trigger to match feat/flatpak Single * doesn't match path separators in GitHub Actions branch filters. Co-Authored-By: Claude Opus 4.6 * feat: add aarch64 Flatpak build and caching to CI - Add matrix strategy for parallel x86_64 and aarch64 builds - Patch protoc URL/sha256 per architecture at build time - Cache .flatpak-builder directory keyed on arch + manifest + lockfile - Pin actions/cache to SHA Co-Authored-By: Claude Opus 4.6 * fix: convert desktop and metainfo files to LF line endings Flatpak builder validates desktop files and rejects CRLF line endings. Co-Authored-By: Claude Opus 4.6 * build: cancel in-progress Flatpak builds on new push Add concurrency group keyed on git ref so a new push cancels any running build for the same branch or release. Co-Authored-By: Claude Opus 4.6 * fix: address PR review findings for Flatpak packaging - Remove unnecessary --filesystem=xdg-config/dash-evo-tool:create (Flatpak already redirects XDG_CONFIG_HOME to sandbox) - Add categories and keywords to AppStream metainfo for discoverability - Update README with both x86_64/aarch64 install commands, uninstall instructions, and Flatpak data path note - Clarify aarch64 comment in manifest to reference CI sed patching Co-Authored-By: Claude Opus 4.6 * chore: workflow timeout and perms * fix: move permissions to job level in Flatpak workflow Step-level permissions are not valid in GitHub Actions. Move contents: write to the job level where it is needed for release attachment. Co-Authored-By: Claude Opus 4.6 * build: cache Cargo registry and target in Flatpak CI Bind-mount host-side cargo-cache and cargo-target directories into the Flatpak build sandbox so CARGO_HOME and target/ persist across builds. Uses split restore/save with cleanup of incremental and registry/src (similar to Swatinem/rust-cache). Co-Authored-By: Claude Opus 4.6 * fix: scope cargo cache bind-mount sed to build-args only The previous sed matched --share=network in both finish-args and build-args, corrupting finish-args. Use a sed range to only target the build-args section. Co-Authored-By: Claude Opus 4.6 * Apply suggestions from code review --------- Co-authored-by: Claude Opus 4.6 --- .github/workflows/flatpak.yml | 132 +++++++++++++ .gitignore | 4 + README.md | 37 +++- flatpak/org.dash.DashEvoTool.desktop | 10 + flatpak/org.dash.DashEvoTool.metainfo.xml | 49 +++++ flatpak/org.dash.DashEvoTool.yml | 74 +++++++ snap/snapcraft.yaml | 226 ---------------------- src/main.rs | 4 +- 8 files changed, 306 insertions(+), 230 deletions(-) create mode 100644 .github/workflows/flatpak.yml create mode 100644 flatpak/org.dash.DashEvoTool.desktop create mode 100644 flatpak/org.dash.DashEvoTool.metainfo.xml create mode 100644 flatpak/org.dash.DashEvoTool.yml delete mode 100644 snap/snapcraft.yaml diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml new file mode 100644 index 000000000..c082bd4d9 --- /dev/null +++ b/.github/workflows/flatpak.yml @@ -0,0 +1,132 @@ +name: Build Flatpak + +on: + push: + branches: + - "**flatpak**" + release: + types: + - published + workflow_dispatch: + +concurrency: + group: flatpak-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build-flatpak: + name: Build Flatpak (${{ matrix.arch }}) + permissions: + contents: write + strategy: + matrix: + include: + - arch: x86_64 + runs-on: ubuntu-latest + protoc-zip: protoc-25.2-linux-x86_64.zip + protoc-sha256: 78ab9c3288919bdaa6cfcec6127a04813cf8a0ce406afa625e48e816abee2878 + - arch: aarch64 + runs-on: ubuntu-24.04-arm + protoc-zip: protoc-25.2-linux-aarch_64.zip + protoc-sha256: 07683afc764e4efa3fa969d5f049fbc2bdfc6b4e7786a0b233413ac0d8753f6b + + runs-on: ${{ matrix.runs-on }} + timeout-minutes: 20 + + steps: + - name: Check out code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Install Flatpak and Flatpak Builder + run: | + sudo apt-get update + sudo apt-get install -y flatpak flatpak-builder + + - name: Add Flathub remote + run: | + sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo + + - name: Install Flatpak SDK and runtime + run: | + sudo flatpak install -y --noninteractive flathub \ + org.freedesktop.Platform//25.08 \ + org.freedesktop.Sdk//25.08 \ + org.freedesktop.Sdk.Extension.rust-stable//25.08 \ + org.freedesktop.Sdk.Extension.llvm21//25.08 + echo "=== Disk space after SDK install ===" + df -h + + - name: Patch manifest for architecture + run: | + sed -i "s|protoc-25.2-linux-x86_64.zip|${{ matrix.protoc-zip }}|g" flatpak/org.dash.DashEvoTool.yml + sed -i "s|sha256: 78ab9c3288919bdaa6cfcec6127a04813cf8a0ce406afa625e48e816abee2878|sha256: ${{ matrix.protoc-sha256 }}|" flatpak/org.dash.DashEvoTool.yml + + - name: Prepare Cargo cache + run: mkdir -p cargo-cache/registry cargo-cache/git cargo-target + + - name: Restore Cargo cache + uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: | + cargo-cache + cargo-target + key: cargo-flatpak-${{ matrix.arch }}-${{ hashFiles('Cargo.lock') }} + restore-keys: | + cargo-flatpak-${{ matrix.arch }}- + + - name: Bind-mount Cargo cache into Flatpak sandbox + run: | + WORKSPACE="$(pwd)" + sed -i "/^ build-args:$/,/--share=network/{ + /--share=network/a\\ + - --bind-mount=/run/build/dash-evo-tool/cargo=${WORKSPACE}/cargo-cache\\ + - --bind-mount=/run/build/dash-evo-tool/target=${WORKSPACE}/cargo-target + }" flatpak/org.dash.DashEvoTool.yml + + - name: Cache Flatpak builder + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: .flatpak-builder + key: flatpak-builder-${{ matrix.arch }}-${{ hashFiles('flatpak/org.dash.DashEvoTool.yml', 'Cargo.lock') }} + restore-keys: | + flatpak-builder-${{ matrix.arch }}- + + - name: Build Flatpak + run: | + flatpak-builder --force-clean --repo=flatpak-repo build-dir flatpak/org.dash.DashEvoTool.yml + + - name: Create Flatpak bundle + run: | + flatpak build-bundle flatpak-repo dash-evo-tool-linux-${{ matrix.arch }}.flatpak org.dash.DashEvoTool + + - name: Clean Cargo cache for storage + run: | + rm -rf cargo-target/release/incremental + rm -rf cargo-cache/registry/src + echo "=== Cargo cache sizes ===" + du -sh cargo-cache cargo-target || true + + - name: Save Cargo cache + uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: | + cargo-cache + cargo-target + key: cargo-flatpak-${{ matrix.arch }}-${{ hashFiles('Cargo.lock') }} + + - name: Upload Flatpak bundle as artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: dash-evo-tool-linux-${{ matrix.arch }}-flatpak + path: dash-evo-tool-linux-${{ matrix.arch }}.flatpak + + - name: Attach bundle to release + if: github.event_name == 'release' + uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2.2.2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + files: dash-evo-tool-linux-${{ matrix.arch }}.flatpak diff --git a/.gitignore b/.gitignore index f038c0579..60cccf155 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ build-test/ # Build artifacts *.snap +*.flatpak +.flatpak-builder/ +flatpak-repo/ +build-dir/ # Dot env file .env diff --git a/README.md b/README.md index 0c9cd47ce..1d3d1d238 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,13 @@ The tool supports both Mainnet and Testnet networks. Check out the [documentatio ## Table of Contents +- [Install via Flatpak (Linux)](#install-via-flatpak-linux) - [Prerequisites](#prerequisites) - [Rust Installation](#rust-installation) - [Dependencies](#dependencies) - [Windows Runtime Dependencies](#windows-runtime-dependencies) - [Dash Core Wallet Setup](#dash-core-wallet-setup) -- [Installation](#installation) +- [Build from Source](#build-from-source) - [Getting Started](#getting-started) - [Start the App](#start-the-app) - [Application directory](#application-directory) @@ -31,8 +32,38 @@ The tool supports both Mainnet and Testnet networks. Check out the [documentatio - [Support](#support) - [Security Note](#security-note) +## Install via Flatpak (Linux) + +The easiest way to run Dash Evo Tool on Linux is via Flatpak. Download the `.flatpak` bundle for your architecture from the [latest release](https://github.com/dashpay/dash-evo-tool/releases) and install it: + +``` shell +# x86_64 +flatpak install dash-evo-tool-linux-x86_64.flatpak + +# aarch64 (ARM) +flatpak install dash-evo-tool-linux-aarch64.flatpak +``` + +To run: + +``` shell +flatpak run org.dash.DashEvoTool +``` + +To uninstall: + +``` shell +flatpak uninstall org.dash.DashEvoTool +``` + +The Flatpak version runs in SPV (light client) mode — no full Dash Core node is required. Application data is stored in `~/.var/app/org.dash.DashEvoTool/config/dash-evo-tool/`. + +> **Note:** The Flatpak data path differs from native Linux builds, which use `~/.config/dash-evo-tool/`. + ## Prerequisites +The following prerequisites are only needed if you want to build from source. + Before you begin, ensure you have met the following requirements: ### Rust Installation @@ -82,9 +113,9 @@ If you use the prebuilt Windows binary, make sure the target machine has: - **Synchronize Wallet**: Ensure the wallet is fully synced with the network you intend to use (Mainnet or Testnet). -## Installation +## Build from Source -To install Dash Evo Tool: +To build Dash Evo Tool from source: 1. **Clone the repository**: diff --git a/flatpak/org.dash.DashEvoTool.desktop b/flatpak/org.dash.DashEvoTool.desktop new file mode 100644 index 000000000..98b91e1ce --- /dev/null +++ b/flatpak/org.dash.DashEvoTool.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Name=Dash Evo Tool +Comment=Graphical user interface for interacting with Dash Evolution +Exec=dash-evo-tool +Icon=org.dash.DashEvoTool +Type=Application +Terminal=false +Categories=Finance;Network;Utility; +Keywords=Dash;Blockchain;Crypto;Wallet;DPNS;Evolution; +StartupWMClass=org.dash.DashEvoTool diff --git a/flatpak/org.dash.DashEvoTool.metainfo.xml b/flatpak/org.dash.DashEvoTool.metainfo.xml new file mode 100644 index 000000000..ca5049c72 --- /dev/null +++ b/flatpak/org.dash.DashEvoTool.metainfo.xml @@ -0,0 +1,49 @@ + + + org.dash.DashEvoTool + Dash Evo Tool + Graphical user interface for interacting with Dash Platform + + MIT + MIT + + +

+ A cross-platform desktop application for interacting with Dash Platform + (Layer 2). Features include DPNS username registration, contest voting, + state transition viewing, wallet management, and identity operations. + Works in SPV (light client) mode - no full Dash Core node required. +

+
+ + + Finance + Network + BlockchainTools + + + + Dash + cryptocurrency + blockchain + wallet + DPNS + + + https://github.com/dashpay/dash-evo-tool + https://github.com/dashpay/dash-evo-tool/issues + + org.dash.DashEvoTool.desktop + + org.dash.DashEvoTool + + + + + Dash Core Group + + + + + +
diff --git a/flatpak/org.dash.DashEvoTool.yml b/flatpak/org.dash.DashEvoTool.yml new file mode 100644 index 000000000..10583c21a --- /dev/null +++ b/flatpak/org.dash.DashEvoTool.yml @@ -0,0 +1,74 @@ +# Flatpak manifest for Dash Evo Tool +# Build with: flatpak-builder --user --force-clean --repo=flatpak-repo build-dir flatpak/org.dash.DashEvoTool.yml +# Install locally: flatpak-builder --user --install --force-clean build-dir flatpak/org.dash.DashEvoTool.yml +# Create bundle: flatpak build-bundle flatpak-repo dash-evo-tool.flatpak org.dash.DashEvoTool + +app-id: org.dash.DashEvoTool +runtime: org.freedesktop.Platform +runtime-version: '25.08' +sdk: org.freedesktop.Sdk +sdk-extensions: + - org.freedesktop.Sdk.Extension.rust-stable + - org.freedesktop.Sdk.Extension.llvm21 + +command: dash-evo-tool + +finish-args: + # X11 shared memory for display + - --share=ipc + # Network access for blockchain communication + - --share=network + # Display server support (prefer Wayland, fall back to X11) + - --socket=wayland + - --socket=fallback-x11 + # GPU acceleration for egui rendering + - --device=dri + +build-options: + append-path: /usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm21/bin + build-args: + - --share=network + env: + CARGO_HOME: /run/build/dash-evo-tool/cargo + PROTOC: /app/bin/protoc + PROTOC_INCLUDE: /app/include + +modules: + # Module 1: Install protobuf compiler (required for building dash-sdk) + # NOTE: CI patches this module for aarch64 via sed (see .github/workflows/flatpak.yml). + - name: protoc + buildsystem: simple + sources: + - type: file + url: https://github.com/protocolbuffers/protobuf/releases/download/v25.2/protoc-25.2-linux-x86_64.zip + sha256: 78ab9c3288919bdaa6cfcec6127a04813cf8a0ce406afa625e48e816abee2878 + build-commands: + - unzip protoc-25.2-linux-x86_64.zip bin/protoc -d /app + - unzip protoc-25.2-linux-x86_64.zip 'include/*' -d /app + - chmod 755 /app/bin/protoc + + # Module 2: Build libsodium (required for crypto operations) + - name: libsodium + buildsystem: autotools + sources: + - type: archive + url: https://github.com/jedisct1/libsodium/releases/download/1.0.20-RELEASE/libsodium-1.0.20.tar.gz + sha256: ebb65ef6ca439333c2bb41a0c1990587288da07f6c7fd07cb3a18cc18d30ce19 + + # Module 3: Build dash-evo-tool from source + - name: dash-evo-tool + buildsystem: simple + sources: + - type: dir + path: .. + skip: + - .flatpak-builder + - build-dir + - flatpak-repo + - target + build-commands: + - cargo build --release --locked + - install -Dm755 target/release/dash-evo-tool /app/bin/dash-evo-tool + - install -Dm644 flatpak/org.dash.DashEvoTool.desktop /app/share/applications/org.dash.DashEvoTool.desktop + - install -Dm644 flatpak/org.dash.DashEvoTool.metainfo.xml /app/share/metainfo/org.dash.DashEvoTool.metainfo.xml + - install -Dm644 assets/DET_LOGO.png /app/share/icons/hicolor/512x512/apps/org.dash.DashEvoTool.png diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml deleted file mode 100644 index 41c74408b..000000000 --- a/snap/snapcraft.yaml +++ /dev/null @@ -1,226 +0,0 @@ -name: dash-evo-tool -title: Dash Evo Tool -icon: mac_os/AppIcons/Assets.xcassets/AppIcon.appiconset/512.png -type: app -version: "0.9.0-preview.4" -summary: Graphical user interface for interacting with Dash Evolution -description: | - Dash Evo Tool is a graphical user interface for easily interacting with - Dash Evolution. The current version enables registering DPNS usernames, - viewing active DPNS username contests, voting on active contests, and - decoding state transitions. The tool supports both Mainnet and Testnet networks. - -grade: devel -confinement: strict - -base: core24 - -platforms: - amd64: - arm64: - -apps: - dash-evo-tool: - command: bin/dash-evo-tool - desktop: usr/share/applications/dash-evo-tool.desktop - environment: - XKB_CONFIG_ROOT: $SNAP/usr/share/X11/xkb - plugs: - - home - - network - - network-bind - - desktop - - desktop-legacy - - wayland - - x11 - - opengl - - gsettings - - screen-inhibit-control - - browser-support - - process-control # Required to spawn external processes - # - system-files # Required to access system binaries - -parts: - rust-deps: - plugin: nil - build-packages: - - curl - - build-essential - - pkg-config - - libssl-dev - override-build: | - # Install Rust - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.92 - export PATH="$HOME/.cargo/bin:$PATH" - rustc --version - cargo --version - - protoc: - plugin: nil - build-packages: - - curl - - unzip - override-build: | - # Install protoc to stage directory (build-time only) - PROTOC_VERSION="31.1" - if [ "$CRAFT_ARCH_BUILD_ON" = "arm64" ]; then - PROTOC_ARCH="aarch_64" - else - PROTOC_ARCH="x86_64" - fi - curl -OL "https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-${PROTOC_ARCH}.zip" - unzip -o "protoc-${PROTOC_VERSION}-linux-${PROTOC_ARCH}.zip" -d $SNAPCRAFT_STAGE bin/protoc - unzip -o "protoc-${PROTOC_VERSION}-linux-${PROTOC_ARCH}.zip" -d $SNAPCRAFT_STAGE 'include/*' - chmod +x $SNAPCRAFT_STAGE/bin/protoc - - dash-evo-tool: - after: [rust-deps, protoc] - plugin: nil - source: . - build-packages: - - build-essential - - pkg-config - - clang - - cmake - - libsqlite3-dev - - libxcb-xfixes0-dev - - libxcb-shape0-dev - - libxcb-randr0-dev - - libxcb-xkb-dev - - libxkbcommon-x11-dev - - libxkbcommon-dev - - libssl-dev - - libsodium-dev - - libbsd-dev - - libc6-dev - - libclang-dev - - xkb-data - stage-packages: - - xkb-data - - libxkbcommon0 - - libxkbcommon-x11-0 - - libxcb-xfixes0 - - libxcb-shape0 - - libxcb-randr0 - - libxcb-xkb1 - - libssl3 - - libsodium23 - - libbsd0 - - libsqlite3-0 - - libfontconfig1 - - libfreetype6 - - libx11-6 - - libx11-xcb1 - - libxcursor1 - - libxi6 - - libxrandr2 - - libgl1-mesa-dri - - libglu1-mesa - - libwayland-client0 - - libwayland-cursor0 - - libwayland-egl1 - - mesa-utils - override-build: | - # Setup environment - export PATH="$HOME/.cargo/bin:$PATH" - export PROTOC="$SNAPCRAFT_STAGE/bin/protoc" - - # Create necessary directories and copy files - mkdir -p dash-evo-tool/ - cp .env.example dash-evo-tool/.env - cp -r dash_core_configs/ dash-evo-tool/dash_core_configs - - # Build the project - cargo build --release - - # Install binary - mkdir -p $SNAPCRAFT_PART_INSTALL/bin - cp target/release/dash-evo-tool $SNAPCRAFT_PART_INSTALL/bin/ - - # Install configuration - mkdir -p $SNAPCRAFT_PART_INSTALL/etc/dash-evo-tool - cp -r dash_core_configs/ $SNAPCRAFT_PART_INSTALL/etc/dash-evo-tool/ - cp .env.example $SNAPCRAFT_PART_INSTALL/etc/dash-evo-tool/.env - - # Create desktop file - mkdir -p $SNAPCRAFT_PART_INSTALL/usr/share/applications - cat > $SNAPCRAFT_PART_INSTALL/usr/share/applications/dash-evo-tool.desktop << EOF - [Desktop Entry] - Name=Dash Evo Tool - Comment=Graphical user interface for interacting with Dash Evolution - Exec=dash-evo-tool - Icon=\${SNAP}/usr/share/icons/hicolor/256x256/apps/dash-evo-tool.png - Type=Application - Categories=Utility;Finance;Network; - StartupWMClass=dash-evo-tool - X-SnapInstanceName=dash-evo-tool - EOF - - # Install icon - mkdir -p $SNAPCRAFT_PART_INSTALL/usr/share/icons/hicolor/256x256/apps - if [ -f "mac_os/AppIcons/Assets.xcassets/AppIcon.appiconset/256.png" ]; then - cp mac_os/AppIcons/Assets.xcassets/AppIcon.appiconset/256.png $SNAPCRAFT_PART_INSTALL/usr/share/icons/hicolor/256x256/apps/dash-evo-tool.png - else - # Create a placeholder icon if the original doesn't exist - convert -size 256x256 xc:lightblue -gravity center -pointsize 24 -annotate +0+0 "Dash" $SNAPCRAFT_PART_INSTALL/usr/share/icons/hicolor/256x256/apps/dash-evo-tool.png - fi - - # Include dash-qt in the snap package - dash-qt: - plugin: nil - build-packages: - - curl - - unzip - stage-packages: - # XCB libraries for X11 support - - libxcb-icccm4 - - libxcb-image0 - - libxcb-shm0 - - libxcb-keysyms1 - - libxcb-randr0 - - libxcb-render-util0 - - libxcb-render0 - - libxcb-shape0 - - libxcb-sync1 - - libxcb-xfixes0 - - libxcb-xinerama0 - - libxcb-xkb1 - - libxcb1 - - libxcb-util1 - # X11 authentication - - libxau6 - - libxdmcp6 - # Font and graphics support - - libfontconfig1 - - libfreetype6 - - libexpat1 - - libpng16-16 - # BSD libraries - - libbsd0 - - libmd0 - # XKB support - - libxkbcommon0 - - libxkbcommon-x11-0 - # Core system libraries (usually included but ensure they're available) - - libgcc-s1 - - libc6 - override-build: | - # Download and install dash-qt - DASH_VERSION="22.1.2" - if [ "$CRAFT_ARCH_BUILD_FOR" = "arm64" ]; then - DASH_ARCH="aarch64" - else - DASH_ARCH="x86_64" - fi - - # Download from official Dash releases - curl -L "https://github.com/dashpay/dash/releases/download/v${DASH_VERSION}/dashcore-${DASH_VERSION}-${DASH_ARCH}-linux-gnu.tar.gz" -o dash.tar.gz - tar -xzf dash.tar.gz - - # Install binaries - mkdir -p $SNAPCRAFT_PART_INSTALL/bin $SNAPCRAFT_PART_INSTALL/lib - cp dashcore-${DASH_VERSION}/bin/dash-qt $SNAPCRAFT_PART_INSTALL/bin/ - cp dashcore-${DASH_VERSION}/lib/* $SNAPCRAFT_PART_INSTALL/lib/ - - # Make executable - chmod +x $SNAPCRAFT_PART_INSTALL/bin/* diff --git a/src/main.rs b/src/main.rs index 0d0121cb0..2b364f62d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -52,7 +52,9 @@ async fn start(app_data_dir: &std::path::Path) -> Result<(), eframe::Error> { persist_window: true, // Persist window size and position centered: true, // Center window on startup if not maximized persistence_path: Some(app_data_dir.join("app.ron")), - viewport: egui::ViewportBuilder::default().with_icon(icon_data), + viewport: egui::ViewportBuilder::default() + .with_icon(icon_data) + .with_app_id("org.dash.DashEvoTool"), ..Default::default() }; From 9bc8ff74503c7b7489cea8361596e1e6296f00a8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:39:34 +0100 Subject: [PATCH 09/19] chore: fix build --- src/ui/wallets/wallets_screen/dialogs.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ui/wallets/wallets_screen/dialogs.rs b/src/ui/wallets/wallets_screen/dialogs.rs index d48eebc57..ac3359a12 100644 --- a/src/ui/wallets/wallets_screen/dialogs.rs +++ b/src/ui/wallets/wallets_screen/dialogs.rs @@ -15,7 +15,6 @@ use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use eframe::egui::{self, ComboBox, Context}; use eframe::epaint::TextureHandle; -use egui::Color32; use egui::load::SizedTexture; use egui::{Color32, Frame, Margin, RichText, TextureOptions}; use std::sync::{Arc, RwLock}; From eac9bf1ab8748351389239225d6d3afd0d6ba943 Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:08:20 +0100 Subject: [PATCH 10/19] Update src/app.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/app.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index b09102ba6..5cc7f5edd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -859,7 +859,8 @@ impl App for AppState { self.visible_screen_mut().refresh(); } _ => { - MessageBanner::set_global(ctx, "Success", MessageType::Success); + // For all other success results, let the screen decide how to display + // the outcome without showing a generic global success banner. self.visible_screen_mut() .display_task_result(unboxed_message); } From 8f80a07f61fb37a54f2afefa33af62d786f9a1a1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:15:09 +0100 Subject: [PATCH 11/19] chore: peer review --- doc/COMPONENT_DESIGN_PATTERN.md | 6 ++++-- docs/ai-design/2026-02-17-unified-messages/architecture.md | 6 +++--- .../2026-02-17-unified-messages/message-banner-ux-spec.md | 4 ++-- src/ui/identities/identities_screen.rs | 2 +- src/ui/identities/top_up_identity_screen/mod.rs | 2 +- src/ui/tokens/mint_tokens_screen.rs | 2 +- 6 files changed, 12 insertions(+), 10 deletions(-) diff --git a/doc/COMPONENT_DESIGN_PATTERN.md b/doc/COMPONENT_DESIGN_PATTERN.md index 0c247a073..60e93bcf5 100644 --- a/doc/COMPONENT_DESIGN_PATTERN.md +++ b/doc/COMPONENT_DESIGN_PATTERN.md @@ -103,11 +103,13 @@ See `AmountInput` in `src/ui/components/amount_input.rs` for a complete example. ## Display-Only Components -Not all components produce domain values. **Display-only** components like `MessageBanner`, `StyledButton`, `GradientHeading`, and `InfoPopup` consume data for rendering and do not implement the `Component` trait. They follow the same structural conventions (private fields, `new()` constructor, builder methods, `show()`) but omit `ComponentResponse` and `update()`. +Not all components produce domain values. **Display-only** components like `StyledButton`, `GradientHeading`, and `InfoPopup` consume data for rendering and do not implement the `Component` trait. They follow the same structural conventions (private fields, `new()` constructor, builder methods, `show()`) but omit `ComponentResponse` and `update()`. + +`MessageBanner` is a **hybrid**: it implements the `Component` trait for per-instance usage (owned by a screen struct), and it also exposes a global API via static methods for app-wide messaging. ### Global State via egui Context -`MessageBanner` demonstrates a pattern for **app-wide UI state** stored in egui context data rather than in a screen struct. This is useful when the same widget must be accessible from multiple call sites (e.g., `AppState::update()` sets it, `island_central_panel()` renders it). +In addition to its per-screen `Component` usage, `MessageBanner` demonstrates a pattern for **app-wide UI state** stored in egui context data rather than in a screen struct. This is useful when the same widget must be accessible from multiple call sites (e.g., `AppState::update()` sets it, `island_central_panel()` renders it). ```rust // Setting from anywhere with &egui::Context diff --git a/docs/ai-design/2026-02-17-unified-messages/architecture.md b/docs/ai-design/2026-02-17-unified-messages/architecture.md index 9b031a8b1..e529d79db 100644 --- a/docs/ai-design/2026-02-17-unified-messages/architecture.md +++ b/docs/ai-design/2026-02-17-unified-messages/architecture.md @@ -124,7 +124,7 @@ The `Component` trait implementation: Both global and per-instance paths call `render_banner()`: -``` +```rust render_banner(ui, text, message_type, annotation: Option<&str>) -> bool (dismissed?) ``` @@ -132,7 +132,7 @@ The `annotation` parameter is generic — it receives either a countdown string ### Visual Structure -``` +```text +-----------------------------------------------------------------------+ | [Icon] Message text here [5s] [x] | +-----------------------------------------------------------------------+ @@ -153,7 +153,7 @@ All colors are resolved via `DashColors` methods — zero hardcoded `Color32` va `AppState::update()` in `src/app.rs` sets global banners automatically for all task results: -``` +```text TaskResult::Error(message) → MessageBanner::set_global(ctx, &message, MessageType::Error) → screen.display_message(&message, MessageType::Error) // for side-effects diff --git a/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md b/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md index cd325a0cd..89f657ce8 100644 --- a/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md +++ b/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md @@ -28,7 +28,7 @@ All four types display a dismiss button (`x`). Success and Info also show a coun ### 3.1 Layout Structure -``` +```text +-----------------------------------------------------------------------+ | [Icon] Message text here [5s] [x] | +-----------------------------------------------------------------------+ @@ -78,7 +78,7 @@ Background uses low alpha (8% light, 12% dark) for subtle tinting. Border uses ` Global banners render at the top of the content area inside `island_central_panel()`, before any screen content: -``` +```text +--------------------------------------------------+ | Top Panel (header / navigation) | +--------------------------------------------------+ diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index 693663bae..00136501e 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -1083,7 +1083,7 @@ impl ScreenLike for IdentitiesScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if let MessageType::Error = message_type + if matches!(message_type, MessageType::Error | MessageType::Warning) && let Some(handle) = self.refresh_banner.take() { handle.clear(); diff --git a/src/ui/identities/top_up_identity_screen/mod.rs b/src/ui/identities/top_up_identity_screen/mod.rs index 19b63d588..cd13a469a 100644 --- a/src/ui/identities/top_up_identity_screen/mod.rs +++ b/src/ui/identities/top_up_identity_screen/mod.rs @@ -428,7 +428,7 @@ impl TopUpIdentityScreen { impl ScreenLike for TopUpIdentityScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if message_type == MessageType::Error { + if matches!(message_type, MessageType::Error | MessageType::Warning) { // Reset step so UI is not stuck on waiting messages let mut step = self.step.write().unwrap(); if *step == WalletFundedScreenStep::WaitingForPlatformAcceptance diff --git a/src/ui/tokens/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs index 3e96e83a1..a2bf989ad 100644 --- a/src/ui/tokens/mint_tokens_screen.rs +++ b/src/ui/tokens/mint_tokens_screen.rs @@ -366,7 +366,7 @@ impl MintTokensScreen { impl ScreenLike for MintTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. - if let MessageType::Error = message_type { + if matches!(message_type, MessageType::Error | MessageType::Warning) { self.status = MintTokensStatus::Error; } } From c04dd50e187ce40aeca0760a0275be927353f799 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:37:40 +0100 Subject: [PATCH 12/19] feat: add collapsible details and suggestion support to MessageBanner Enhance BannerState with optional details (collapsible technical info) and suggestion (inline recovery hint) fields. BannerHandle gains with_details() and with_suggestion() builder methods and is now Clone. The details section renders collapsed by default with a "Show details" toggle and scrollable monospace content area. Co-Authored-By: Claude Opus 4.6 --- src/ui/components/message_banner.rs | 125 +++++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 4 deletions(-) diff --git a/src/ui/components/message_banner.rs b/src/ui/components/message_banner.rs index 476f9c6d3..629c4ec94 100644 --- a/src/ui/components/message_banner.rs +++ b/src/ui/components/message_banner.rs @@ -8,6 +8,8 @@ use std::time::{Duration, Instant}; const DEFAULT_AUTO_DISMISS: Duration = Duration::from_secs(5); const MAX_BANNERS: usize = 5; const BANNER_STATE_ID: &str = "__global_message_banner"; +/// Maximum height for the expanded details section before scrolling. +const DETAILS_MAX_HEIGHT: f32 = 120.0; /// Monotonic counter for generating unique banner keys. static BANNER_KEY_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -65,6 +67,12 @@ struct BannerState { auto_dismiss_after: Option, /// When `true`, display elapsed time since creation instead of countdown. show_elapsed: bool, + /// Optional technical details (shown in a collapsible section). + details: Option, + /// Optional recovery suggestion (shown inline below the summary). + suggestion: Option, + /// Whether the details section is currently expanded. + details_expanded: bool, } /// Handle for a global banner, returned by [`MessageBanner::set_global`] and @@ -73,6 +81,7 @@ struct BannerState { /// /// The handle is `'static` and safe to store. Methods that modify the banner /// (`set_text`, `with_auto_dismiss`) take `&self` so the handle can be reused. +#[derive(Clone)] pub struct BannerHandle { ctx: egui::Context, key: u64, @@ -123,6 +132,34 @@ impl BannerHandle { Some(self) } + /// Attach optional technical details to this banner. + /// Details are shown in a collapsible section (collapsed by default). + /// Returns `None` if the banner no longer exists. + pub fn with_details(&self, details: &str) -> Option<&Self> { + if details.is_empty() { + return Some(self); + } + let mut banners = get_banners(&self.ctx); + let b = banners.iter_mut().find(|b| b.key == self.key)?; + b.details = Some(details.to_string()); + set_banners(&self.ctx, banners); + Some(self) + } + + /// Attach an optional recovery suggestion to this banner. + /// The suggestion is shown inline (visible without expanding). + /// Returns `None` if the banner no longer exists. + pub fn with_suggestion(&self, suggestion: &str) -> Option<&Self> { + if suggestion.is_empty() { + return Some(self); + } + let mut banners = get_banners(&self.ctx); + let b = banners.iter_mut().find(|b| b.key == self.key)?; + b.suggestion = Some(suggestion.to_string()); + set_banners(&self.ctx, banners); + Some(self) + } + /// Remove this banner immediately. pub fn clear(self) { let mut banners = get_banners(&self.ctx); @@ -162,6 +199,9 @@ impl MessageBanner { created_at: Instant::now(), auto_dismiss_after: default_auto_dismiss(message_type), show_elapsed: false, + details: None, + suggestion: None, + details_expanded: false, }); } self @@ -218,6 +258,9 @@ impl MessageBanner { created_at: Instant::now(), auto_dismiss_after: default_auto_dismiss(message_type), show_elapsed: false, + details: None, + suggestion: None, + details_expanded: false, }); if banners.len() > MAX_BANNERS { banners.remove(0); @@ -267,6 +310,9 @@ impl MessageBanner { created_at: Instant::now(), auto_dismiss_after: default_auto_dismiss(message_type), show_elapsed: false, + details: None, + suggestion: None, + details_expanded: false, }); if banners.len() > MAX_BANNERS { banners.remove(0); @@ -299,7 +345,7 @@ impl MessageBanner { if banners.is_empty() { return; } - banners.retain(|b| process_banner(ui, b) == BannerStatus::Visible); + banners.retain_mut(|b| process_banner(ui, b) == BannerStatus::Visible); set_banners(ui.ctx(), banners); } } @@ -315,7 +361,7 @@ impl Component for MessageBanner { type Response = MessageBannerResponse; fn show(&mut self, ui: &mut egui::Ui) -> InnerResponse { - let Some(state) = &self.state else { + let Some(state) = &mut self.state else { return empty_response(ui); }; let status = process_banner(ui, state); @@ -363,7 +409,7 @@ fn empty_response(ui: &mut egui::Ui) -> InnerResponse { /// Processes a single banner: checks expiry, renders, handles dismiss, requests repaint. /// Returns the banner's resulting status. -fn process_banner(ui: &mut egui::Ui, state: &BannerState) -> BannerStatus { +fn process_banner(ui: &mut egui::Ui, state: &mut BannerState) -> BannerStatus { let elapsed = state.created_at.elapsed(); // Check auto-dismiss expiry @@ -383,7 +429,15 @@ fn process_banner(ui: &mut egui::Ui, state: &BannerState) -> BannerStatus { None }; - if render_banner(ui, &state.text, state.message_type, annotation.as_deref()) { + if render_banner( + ui, + &state.text, + state.message_type, + annotation.as_deref(), + state.suggestion.as_deref(), + state.details.as_deref(), + &mut state.details_expanded, + ) { return BannerStatus::Dismissed; } if state.auto_dismiss_after.is_some() || state.show_elapsed { @@ -399,6 +453,9 @@ fn render_banner( text: &str, message_type: MessageType, annotation: Option<&str>, + suggestion: Option<&str>, + details: Option<&str>, + details_expanded: &mut bool, ) -> bool { let dark_mode = ui.ctx().style().visuals.dark_mode; let fg_color = DashColors::message_color(message_type, dark_mode); @@ -437,6 +494,66 @@ fn render_banner( } }); }); + + // Recovery suggestion (always visible, inline) + if let Some(suggestion) = suggestion { + ui.add_space(2.0); + ui.add( + egui::Label::new( + egui::RichText::new(suggestion) + .color(secondary_color) + .italics() + .font(Typography::body_small()), + ) + .wrap(), + ); + } + + // Technical details (collapsible) + if let Some(details) = details { + ui.add_space(2.0); + let toggle_text = if *details_expanded { + "Hide details" + } else { + "Show details" + }; + if ui + .add( + egui::Label::new( + egui::RichText::new(toggle_text) + .font(Typography::body_small()) + .color(DashColors::DASH_BLUE) + .underline(), + ) + .sense(egui::Sense::click()), + ) + .clicked() + { + *details_expanded = !*details_expanded; + } + + if *details_expanded { + ui.add_space(4.0); + egui::Frame::new() + .fill(DashColors::input_background(dark_mode)) + .inner_margin(egui::Margin::symmetric(8, 6)) + .corner_radius(Shape::RADIUS_SM as f32) + .show(ui, |ui| { + egui::ScrollArea::vertical() + .max_height(DETAILS_MAX_HEIGHT) + .show(ui, |ui| { + ui.add( + egui::Label::new( + egui::RichText::new(details) + .font(Typography::monospace()) + .color(secondary_color), + ) + .wrap(), + ); + }); + }); + } + } }); ui.add_space(Spacing::SM); From d9f7ce68c64f0539b49f54efd312d097c2ab3fbe Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:05:04 +0100 Subject: [PATCH 13/19] fix: correct MessageBanner dedup, replace_global reset, and doc drift - set_global now updates message_type on deduplicated banners - replace_global resets details/suggestion/details_expanded on replacement - Fix doc comment referencing set_text instead of set_message - Update architecture.md BannerState struct with details/suggestion fields - Update architecture.md render_banner signature and BannerHandle method table Co-Authored-By: Claude Opus 4.6 --- .../2026-02-17-unified-messages/architecture.md | 17 ++++++++++++++--- src/ui/components/message_banner.rs | 9 +++++++-- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/docs/ai-design/2026-02-17-unified-messages/architecture.md b/docs/ai-design/2026-02-17-unified-messages/architecture.md index e529d79db..32474f38a 100644 --- a/docs/ai-design/2026-02-17-unified-messages/architecture.md +++ b/docs/ai-design/2026-02-17-unified-messages/architecture.md @@ -27,6 +27,9 @@ struct BannerState { created_at: Instant, // Monotonic timestamp for timing auto_dismiss_after: Option, // None = persistent, Some = countdown show_elapsed: bool, // Show elapsed time instead of countdown + details: Option, // Technical details (collapsible section) + suggestion: Option, // Recovery suggestion (shown inline) + details_expanded: bool, // Whether details section is expanded } ``` @@ -53,9 +56,11 @@ All query/mutation methods return `Option` to handle the case where the banner h | `set_message()` | `(&self, text: &str) -> Option<&Self>` | Update display text | | `with_auto_dismiss()` | `(&self, Duration) -> Option<&Self>` | Set/override countdown duration; resets timer to now | | `with_elapsed()` | `-> Option<&Self>` | Enable elapsed-time display mode (disables auto-dismiss) | +| `with_details()` | `(&self, &str) -> Option<&Self>` | Attach collapsible technical details section | +| `with_suggestion()` | `(&self, &str) -> Option<&Self>` | Attach inline recovery suggestion | | `clear()` | `(self)` | Remove banner immediately (consumes handle) | -Methods that modify the banner (`set_message`, `with_auto_dismiss`, `with_elapsed`) return early without writing back to context data when the banner no longer exists. +Methods that modify the banner (`set_message`, `with_auto_dismiss`, `with_elapsed`, `with_details`, `with_suggestion`) return early without writing back to context data when the banner no longer exists. --- @@ -125,10 +130,16 @@ The `Component` trait implementation: Both global and per-instance paths call `render_banner()`: ```rust -render_banner(ui, text, message_type, annotation: Option<&str>) -> bool (dismissed?) +render_banner( + ui, text, message_type, + annotation: Option<&str>, + suggestion: Option<&str>, + details: Option<&str>, + details_expanded: &mut bool, +) -> bool (dismissed?) ``` -The `annotation` parameter is generic — it receives either a countdown string `"(3s)"`, an elapsed string `"(5s)"`, or `None` for persistent banners. This is computed by `process_banner()` which handles the lifecycle logic. +The `annotation` parameter is generic — it receives either a countdown string `"(3s)"`, an elapsed string `"(5s)"`, or `None` for persistent banners. This is computed by `process_banner()` which handles the lifecycle logic. The `suggestion` is shown inline below the message text, and `details` renders in a collapsible section (controlled by `details_expanded`). ### Visual Structure diff --git a/src/ui/components/message_banner.rs b/src/ui/components/message_banner.rs index 629c4ec94..d5c3714eb 100644 --- a/src/ui/components/message_banner.rs +++ b/src/ui/components/message_banner.rs @@ -80,7 +80,7 @@ struct BannerState { /// so the display text can be updated without losing the reference. /// /// The handle is `'static` and safe to store. Methods that modify the banner -/// (`set_text`, `with_auto_dismiss`) take `&self` so the handle can be reused. +/// (`set_message`, `with_auto_dismiss`) take `&self` so the handle can be reused. #[derive(Clone)] pub struct BannerHandle { ctx: egui::Context, @@ -242,8 +242,10 @@ impl MessageBanner { /// Returns a [`BannerHandle`] for updating or clearing the banner later. pub fn set_global(ctx: &egui::Context, text: &str, message_type: MessageType) -> BannerHandle { let mut banners = get_banners(ctx); - if let Some(existing) = banners.iter().find(|b| b.text == text) { + if let Some(existing) = banners.iter_mut().find(|b| b.text == text) { + existing.message_type = message_type; let key = existing.key; + set_banners(ctx, banners); return BannerHandle { ctx: ctx.clone(), key, @@ -299,6 +301,9 @@ impl MessageBanner { b.created_at = Instant::now(); b.auto_dismiss_after = default_auto_dismiss(message_type); b.show_elapsed = false; + b.details = None; + b.suggestion = None; + b.details_expanded = false; } else if let Some(existing) = banners.iter().find(|b| b.text == new_text) { key = existing.key; } else { From 8d9fbc14a8047ebc83eeded5bd20749808da2f9b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:29:11 +0100 Subject: [PATCH 14/19] feat: log everything that is displayed by the MessageBanner --- src/ui/components/message_banner.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/ui/components/message_banner.rs b/src/ui/components/message_banner.rs index d5c3714eb..1bd94a1cf 100644 --- a/src/ui/components/message_banner.rs +++ b/src/ui/components/message_banner.rs @@ -4,6 +4,7 @@ use crate::ui::theme::{DashColors, Shape, Spacing, Typography}; use egui::InnerResponse; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; +use tracing::{debug, error, warn}; const DEFAULT_AUTO_DISMISS: Duration = Duration::from_secs(5); const MAX_BANNERS: usize = 5; @@ -73,6 +74,23 @@ struct BannerState { suggestion: Option, /// Whether the details section is currently expanded. details_expanded: bool, + /// Whether the banner has been logged (to avoid duplicate log entries on each frame). + logged: bool, +} + +impl BannerState { + /// Emits a tracing log for this banner, with log level based on message type. + fn log(&self) { + let text = self.text.as_str(); + let details = self.details.as_deref(); + match self.message_type { + MessageType::Error => error!(banner = text, details, "Banner displayed"), + MessageType::Warning => warn!(banner = text, details, "Banner displayed"), + MessageType::Success | MessageType::Info => { + debug!(banner = text, details, "Banner displayed") + } + } + } } /// Handle for a global banner, returned by [`MessageBanner::set_global`] and @@ -202,6 +220,7 @@ impl MessageBanner { details: None, suggestion: None, details_expanded: false, + logged: false, }); } self @@ -263,6 +282,7 @@ impl MessageBanner { details: None, suggestion: None, details_expanded: false, + logged: false, }); if banners.len() > MAX_BANNERS { banners.remove(0); @@ -304,6 +324,7 @@ impl MessageBanner { b.details = None; b.suggestion = None; b.details_expanded = false; + b.logged = false; } else if let Some(existing) = banners.iter().find(|b| b.text == new_text) { key = existing.key; } else { @@ -318,6 +339,7 @@ impl MessageBanner { details: None, suggestion: None, details_expanded: false, + logged: false, }); if banners.len() > MAX_BANNERS { banners.remove(0); @@ -434,6 +456,12 @@ fn process_banner(ui: &mut egui::Ui, state: &mut BannerState) -> BannerStatus { None }; + // Log banner message once on first display + if !state.logged { + state.logged = true; + state.log(); + } + if render_banner( ui, &state.text, From ab182982b9e7d75d4b984723d4aa74845123bc40 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Feb 2026 10:39:51 +0100 Subject: [PATCH 15/19] fix(ui): address PR #601 review items 12-16 for MessageBanner Add BannerState::new() and reset_to() helpers to eliminate duplicated field initialization across set_message, set_global, and replace_global. Fix dedup paths that forgot to update created_at/auto_dismiss_after/ show_elapsed (items 13-14). Fix concurrent refresh showing premature success by tracking pending_refresh_count (item 15). Remove unreachable match arm and tighten wildcard to guard on identity refresh tasks only (item 16). Update architecture docs to reflect current code (item 12). Co-Authored-By: Claude Opus 4.6 --- .../architecture.md | 3 +- src/ui/components/message_banner.rs | 86 +++++++++---------- src/ui/identities/identities_screen.rs | 42 +++++++-- 3 files changed, 74 insertions(+), 57 deletions(-) diff --git a/docs/ai-design/2026-02-17-unified-messages/architecture.md b/docs/ai-design/2026-02-17-unified-messages/architecture.md index 32474f38a..35b2702bb 100644 --- a/docs/ai-design/2026-02-17-unified-messages/architecture.md +++ b/docs/ai-design/2026-02-17-unified-messages/architecture.md @@ -170,8 +170,7 @@ TaskResult::Error(message) → screen.display_message(&message, MessageType::Error) // for side-effects TaskResult::Success (default) - → MessageBanner::set_global(ctx, "Success", MessageType::Success) - → screen.display_task_result(result) + → screen.display_task_result(result) // screens decide whether to surface a banner ``` The call to `screen.display_message()` is retained alongside `set_global` so screens can perform side-effects (e.g., resetting step state on error, clearing refresh handles) without being responsible for rendering. diff --git a/src/ui/components/message_banner.rs b/src/ui/components/message_banner.rs index 1bd94a1cf..954ce68db 100644 --- a/src/ui/components/message_banner.rs +++ b/src/ui/components/message_banner.rs @@ -79,6 +79,36 @@ struct BannerState { } impl BannerState { + /// Create a fresh banner with a new key and default auto-dismiss for the given type. + fn new(key: u64, text: String, message_type: MessageType) -> Self { + Self { + key, + text, + message_type, + created_at: Instant::now(), + auto_dismiss_after: default_auto_dismiss(message_type), + show_elapsed: false, + details: None, + suggestion: None, + details_expanded: false, + logged: false, + } + } + + /// Reset an existing banner's content, keeping its key. + /// Resets timestamps, clears details/suggestion, resets logged flag. + fn reset_to(&mut self, text: String, message_type: MessageType) { + self.text = text; + self.message_type = message_type; + self.created_at = Instant::now(); + self.auto_dismiss_after = default_auto_dismiss(message_type); + self.show_elapsed = false; + self.details = None; + self.suggestion = None; + self.details_expanded = false; + self.logged = false; + } + /// Emits a tracing log for this banner, with log level based on message type. fn log(&self) { let text = self.text.as_str(); @@ -210,18 +240,11 @@ impl MessageBanner { if text.is_empty() { self.state = None; } else { - self.state = Some(BannerState { - key: next_banner_key(), - text: text.to_string(), + self.state = Some(BannerState::new( + next_banner_key(), + text.to_string(), message_type, - created_at: Instant::now(), - auto_dismiss_after: default_auto_dismiss(message_type), - show_elapsed: false, - details: None, - suggestion: None, - details_expanded: false, - logged: false, - }); + )); } self } @@ -262,7 +285,7 @@ impl MessageBanner { pub fn set_global(ctx: &egui::Context, text: &str, message_type: MessageType) -> BannerHandle { let mut banners = get_banners(ctx); if let Some(existing) = banners.iter_mut().find(|b| b.text == text) { - existing.message_type = message_type; + existing.reset_to(text.to_string(), message_type); let key = existing.key; set_banners(ctx, banners); return BannerHandle { @@ -272,18 +295,7 @@ impl MessageBanner { } let key = next_banner_key(); if !text.is_empty() { - banners.push(BannerState { - key, - text: text.to_string(), - message_type, - created_at: Instant::now(), - auto_dismiss_after: default_auto_dismiss(message_type), - show_elapsed: false, - details: None, - suggestion: None, - details_expanded: false, - logged: false, - }); + banners.push(BannerState::new(key, text.to_string(), message_type)); if banners.len() > MAX_BANNERS { banners.remove(0); } @@ -316,31 +328,13 @@ impl MessageBanner { let key; if let Some(b) = banners.iter_mut().find(|b| b.text == old_text) { key = b.key; - b.text = new_text.to_string(); - b.message_type = message_type; - b.created_at = Instant::now(); - b.auto_dismiss_after = default_auto_dismiss(message_type); - b.show_elapsed = false; - b.details = None; - b.suggestion = None; - b.details_expanded = false; - b.logged = false; - } else if let Some(existing) = banners.iter().find(|b| b.text == new_text) { + b.reset_to(new_text.to_string(), message_type); + } else if let Some(existing) = banners.iter_mut().find(|b| b.text == new_text) { key = existing.key; + existing.reset_to(new_text.to_string(), message_type); } else { key = next_banner_key(); - banners.push(BannerState { - key, - text: new_text.to_string(), - message_type, - created_at: Instant::now(), - auto_dismiss_after: default_auto_dismiss(message_type), - show_elapsed: false, - details: None, - suggestion: None, - details_expanded: false, - logged: false, - }); + banners.push(BannerState::new(key, new_text.to_string(), message_type)); if banners.len() > MAX_BANNERS { banners.remove(0); } diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index c94e8e4db..fa4501aa3 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -61,6 +61,9 @@ pub struct IdentitiesScreen { sort_order: IdentitiesSortOrder, use_custom_order: bool, refresh_banner: Option, + pending_refresh_count: usize, + /// Total identities dispatched in the current refresh batch (for pluralization). + total_refresh_count: usize, // Alias editing state editing_alias_identity: Option, editing_alias_value: String, @@ -86,6 +89,8 @@ impl IdentitiesScreen { sort_order: IdentitiesSortOrder::Ascending, use_custom_order: true, refresh_banner: None, + pending_refresh_count: 0, + total_refresh_count: 0, editing_alias_identity: None, editing_alias_value: String::new(), }; @@ -1118,14 +1123,25 @@ impl ScreenLike for IdentitiesScreen { if let crate::ui::BackendTaskSuccessResult::RefreshedIdentity(_) = backend_task_success_result { - if let Some(handle) = self.refresh_banner.take() { - handle.clear(); + self.pending_refresh_count = self.pending_refresh_count.saturating_sub(1); + if self.pending_refresh_count == 0 { + if let Some(handle) = self.refresh_banner.take() { + handle.clear(); + } + let message = if self.total_refresh_count == 1 { + "Successfully refreshed identity".to_string() + } else { + format!( + "Successfully refreshed {} identities", + self.total_refresh_count + ) + }; + MessageBanner::set_global( + self.app_context.egui_ctx(), + &message, + MessageType::Success, + ); } - MessageBanner::set_global( - self.app_context.egui_ctx(), - "Successfully refreshed identity", - MessageType::Success, - ); } } @@ -1205,11 +1221,19 @@ impl ScreenLike for IdentitiesScreen { }); match action { - AppAction::BackendTask(BackendTask::IdentityTask(IdentityTask::RefreshIdentity(_))) - | AppAction::BackendTasks(_, _) => { + AppAction::BackendTasks(ref tasks, _) + if tasks.iter().all(|t| { + matches!( + t, + BackendTask::IdentityTask(IdentityTask::RefreshIdentity(_)) + ) + }) => + { if let Some(handle) = self.refresh_banner.take() { handle.clear(); } + self.pending_refresh_count = tasks.len(); + self.total_refresh_count = tasks.len(); let handle = MessageBanner::set_global(ctx, "Refreshing identities...", MessageType::Info); handle.with_elapsed(); From d0e6d6ee26d6f86b329753cec4a7ad81e91ee000 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Feb 2026 10:48:28 +0100 Subject: [PATCH 16/19] fix(ui): clarify display_message contract for Success/Info in MintTokensScreen Add explicit comment documenting that Success/Info types require no local state change since the global banner handles display. Addresses PR #601 review comment about asymmetric handling. Co-Authored-By: Claude Opus 4.6 --- src/ui/tokens/mint_tokens_screen.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ui/tokens/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs index 69188eb0b..782624e72 100644 --- a/src/ui/tokens/mint_tokens_screen.rs +++ b/src/ui/tokens/mint_tokens_screen.rs @@ -378,10 +378,11 @@ impl MintTokensScreen { impl ScreenLike for MintTokensScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { - // Banner display is handled globally by AppState; this is only for side-effects. + // Global banner is set by AppState before calling display_message; this only updates status. if matches!(message_type, MessageType::Error | MessageType::Warning) { self.status = MintTokensStatus::Error; } + // Success/Info: no local state change needed; the global banner is the display mechanism. } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { From 83f13e8778146d2a40b28ef199b9f57d78a92da0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Feb 2026 10:51:56 +0100 Subject: [PATCH 17/19] fix(ui): use distinct icon for Error vs Warning banners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Error banners now show ❌ (cross mark) instead of ⚠ (warning sign), so colorblind users can distinguish severity by shape, not just color. Co-Authored-By: Claude Opus 4.6 --- src/ui/components/message_banner.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/components/message_banner.rs b/src/ui/components/message_banner.rs index 954ce68db..3953cf65c 100644 --- a/src/ui/components/message_banner.rs +++ b/src/ui/components/message_banner.rs @@ -605,8 +605,8 @@ fn set_banners(ctx: &egui::Context, banners: Vec) { fn icon_for_type(message_type: MessageType) -> &'static str { match message_type { - MessageType::Error => "\u{26A0}", // warning sign - MessageType::Warning => "\u{26A0}", // warning sign (differentiated by color) + MessageType::Error => "\u{274C}", // cross mark + MessageType::Warning => "\u{26A0}", // warning sign MessageType::Success => "\u{2713}", // check mark MessageType::Info => "\u{2139}", // info } From cfcf039cf89224d0e6012a665e66e101bcc588fa Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:29:28 +0100 Subject: [PATCH 18/19] fix(test): update banner test to expect new Error icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Error icon changed from ⚠ to ❌ in the previous commit. Update test_banner_renders_all_types to match. Co-Authored-By: Claude Opus 4.6 --- tests/kittest/message_banner.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/kittest/message_banner.rs b/tests/kittest/message_banner.rs index 40f4c86a9..4db984f3c 100644 --- a/tests/kittest/message_banner.rs +++ b/tests/kittest/message_banner.rs @@ -79,7 +79,7 @@ fn test_banner_renders_error_message() { #[test] fn test_banner_renders_all_types() { let variants = [ - (MessageType::Error, "Error message", "\u{26A0}"), + (MessageType::Error, "Error message", "\u{274C}"), (MessageType::Warning, "Warning message", "\u{26A0}"), (MessageType::Success, "Success message", "\u{2713}"), (MessageType::Info, "Info message", "\u{2139}"), From 4876892fbe7bc41b047eecbf74040531c1f3922d Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:16:37 +0100 Subject: [PATCH 19/19] Update docs/ai-design/2026-02-17-unified-messages/architecture.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- docs/ai-design/2026-02-17-unified-messages/architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ai-design/2026-02-17-unified-messages/architecture.md b/docs/ai-design/2026-02-17-unified-messages/architecture.md index 35b2702bb..18f05736d 100644 --- a/docs/ai-design/2026-02-17-unified-messages/architecture.md +++ b/docs/ai-design/2026-02-17-unified-messages/architecture.md @@ -150,7 +150,7 @@ The `annotation` parameter is generic — it receives either a countdown string ``` - Frame: `DashColors::message_background_color()` fill, `DashColors::message_color()` border -- Icon: Unicode character (⚠ for Error/Warning, ✓ for Success, ℹ for Info) +- Icon: Unicode character (❌ for Error, ⚠ for Warning, ✓ for Success, ℹ for Info) - Text: `DashColors::message_color()` foreground - Annotation: `DashColors::text_secondary()` color, `Typography::body_small()` font - Dismiss: `ui.small_button("x")`