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..60e93bcf5 100644 --- a/doc/COMPONENT_DESIGN_PATTERN.md +++ b/doc/COMPONENT_DESIGN_PATTERN.md @@ -98,3 +98,27 @@ 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 `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 + +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 +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 new file mode 100644 index 000000000..18f05736d --- /dev/null +++ b/docs/ai-design/2026-02-17-unified-messages/architecture.md @@ -0,0 +1,257 @@ +# MessageBanner Component -- Technical Architecture + +## 1. Overview + +`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: + +- **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. + +Both modes share the same rendering function (`render_banner()`), ensuring visual consistency. + +**File**: `src/ui/components/message_banner.rs` + +--- + +## 2. Data Structures + +### BannerState (private) + +Internal state for a single banner message: + +```rust +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 + details: Option, // Technical details (collapsible section) + suggestion: Option, // Recovery suggestion (shown inline) + details_expanded: bool, // Whether details section is expanded +} +``` + +### MessageType + +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)`. + +### BannerHandle + +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 +pub struct BannerHandle { + ctx: egui::Context, // egui Context is Arc>, cheap to clone + key: u64, // Unique key assigned at creation +} +``` + +All query/mutation methods return `Option` to handle the case where the banner has been dismissed or expired: + +| 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) | +| `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`, `with_details`, `with_suggestion`) return early without writing back to context data when the banner no longer exists. + +--- + +## 3. Global API + +### Multi-Message Support + +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). + +| 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. | + +**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. + +**Empty text**: `set_global("")` is a no-op (returns a dead handle). `replace_global(old, "", type)` clears the old message. + +### Auto-Dismiss Defaults + +| MessageType | Default | +|-------------|---------| +| Success | 5 seconds countdown | +| Info | 5 seconds countdown | +| Error | Persistent (manual dismiss only) | +| Warning | Persistent (manual dismiss only) | + +### Elapsed-Time Mode + +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. + +--- + +## 4. Per-Instance API + +`MessageBanner` as a struct implements the `Component` trait: + +```rust +pub struct MessageBanner { + state: Option, +} +``` + +| 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 | + +`MessageBanner` also implements `Default` (equivalent to `new()`). + +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 + +--- + +## 5. Rendering + +Both global and per-instance paths call `render_banner()`: + +```rust +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 `suggestion` is shown inline below the message text, and `details` renders in a collapsible section (controlled by `details_expanded`). + +### Visual Structure + +```text ++-----------------------------------------------------------------------+ +| [Icon] Message text here [5s] [x] | ++-----------------------------------------------------------------------+ +``` + +- Frame: `DashColors::message_background_color()` fill, `DashColors::message_color()` border +- 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")` +- Spacing: `Spacing::SM` below each banner + +All colors are resolved via `DashColors` methods — zero hardcoded `Color32` values. + +--- + +## 6. AppState Integration + +`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 + +TaskResult::Success (default) + → 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. + +### Rendering Point + +`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. + +--- + +## 7. Usage Example: IdentitiesScreen + +Demonstrates the `BannerHandle` + elapsed-time pattern for a long-running refresh operation: + +```rust +// Struct: stores handle instead of separate refreshing/timestamp fields +refresh_banner: Option, + +// Starting refresh (in ui()): +let handle = MessageBanner::set_global(ctx, "Refreshing identities...", MessageType::Info); +handle.with_elapsed(); +self.refresh_banner = Some(handle); + +// On success (in display_task_result() — no ctx parameter available): +if let Some(handle) = self.refresh_banner.take() { + handle.clear(); +} +MessageBanner::set_global( + self.app_context.egui_ctx(), + "Successfully refreshed identity", + MessageType::Success, +); + +// On error (in display_message() — side-effect only): +if let MessageType::Error = message_type + && let Some(handle) = self.refresh_banner.take() +{ + handle.clear(); +} +``` + +--- + +## 8. Key Design Decisions + +| Decision | Rationale | +|----------|-----------| +| 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. | + +--- + +## 9. Migration Status + +3 of ~50 screens migrated as proof-of-concept: + +| 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. | + +Old and new patterns coexist without conflict because the global banner renders above screen content via `island_central_panel`. + +--- + +## 10. Pre-Migration Analysis + +Before the MessageBanner was implemented, the codebase had: + +- **~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 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-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..89f657ce8 --- /dev/null +++ b/docs/ai-design/2026-02-17-unified-messages/message-banner-ux-spec.md @@ -0,0 +1,163 @@ +# MessageBanner Component -- UX Specification + +## 1. Overview + +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 | 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 (`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. + +--- + +## 3. Visual Design + +### 3.1 Layout Structure + +```text ++-----------------------------------------------------------------------+ +| [Icon] Message text here [5s] [x] | ++-----------------------------------------------------------------------+ +``` + +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** (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. 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** (right-to-left layout for remaining elements) + +5. **Annotation** (optional): Shows remaining seconds `(3s)` or elapsed seconds `(5s)` + - Font: `Typography::body_small()` (14px) + - Color: `DashColors::text_secondary(dark_mode)` + +6. **Dismiss button**: `ui.small_button("x")` + +### 3.3 Color Palette + +All colors resolved through `DashColors` — zero hardcoded values. + +| 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)` | + +Background uses low alpha (8% light, 12% dark) for subtle tinting. Border uses `Shape::BORDER_WIDTH` (1px) in the foreground color. + +--- + +## 4. Placement + +Global banners render at the top of the content area inside `island_central_panel()`, before any screen content: + +```text ++--------------------------------------------------+ +| Top Panel (header / navigation) | ++--------------------------------------------------+ +| Left Panel | [ Banner 1 ] | +| | [ Banner 2 ] | +| | +----- Screen Content -----+ | +| | | ... | | +| | +--------------------------+ | ++--------------------------------------------------+ +``` + +Banners remain visible regardless of scroll position because they render outside `ScrollArea`. + +--- + +## 5. Behavior + +### 5.1 Showing a Message + +`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) + +- Default duration: 5 seconds +- Countdown label: `(5s)`, `(4s)`, ..., `(1s)` +- Banner clears automatically when timer expires +- Component requests repaint every 1s for countdown updates + +### 5.3 Elapsed-Time Mode + +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) + +### 5.4 Manual Dismiss + +- All severity types display a dismiss (`x`) button +- Clicking clears that specific banner immediately +- Other banners are unaffected + +### 5.5 Message Replacement + +`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. + +### 5.6 BannerHandle Lifecycle + +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. + +--- + +## 6. Edge Cases + +| Scenario | Behavior | +|----------|----------| +| 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. | + +--- + +## 7. Accessibility + +- **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. + +--- + +## 8. What This Spec Does NOT Cover + +- 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 +- Changes to BackendTask/TaskResult/AppState routing architecture diff --git a/src/app.rs b/src/app.rs index 5b43ca033..513d3a581 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::{ @@ -187,6 +188,7 @@ impl AppState { password_info.clone(), subtasks.clone(), connection_status.clone(), + ctx.clone(), ) .ok_or("Failed to create AppContext for mainnet. Check your Dash configuration.")?; let testnet_app_context = AppContext::new( @@ -195,6 +197,7 @@ impl AppState { password_info.clone(), subtasks.clone(), connection_status.clone(), + ctx.clone(), ); let devnet_app_context = AppContext::new( Network::Devnet, @@ -202,6 +205,7 @@ impl AppState { password_info.clone(), subtasks.clone(), connection_status.clone(), + ctx.clone(), ); let local_app_context = AppContext::new( Network::Regtest, @@ -209,6 +213,7 @@ impl AppState { password_info, subtasks.clone(), connection_status.clone(), + ctx.clone(), ); // load fonts @@ -917,6 +922,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, @@ -927,6 +937,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, @@ -934,12 +949,15 @@ impl App for AppState { self.visible_screen_mut().refresh(); } _ => { + // 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); } } } 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 2900b6060..868f8b71e 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -98,6 +98,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 { @@ -107,6 +110,7 @@ impl AppContext { password_info: Option, subtasks: Arc, connection_status: Arc, + egui_ctx: egui::Context, ) -> Option> { let config = match Config::load() { Ok(config) => config, @@ -333,6 +337,7 @@ impl AppContext { fee_multiplier_permille: AtomicU64::new( PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE, ), + egui_ctx, }; let app_context = Arc::new(app_context); @@ -397,6 +402,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..3953cf65c --- /dev/null +++ b/src/ui/components/message_banner.rs @@ -0,0 +1,613 @@ +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}; +use tracing::{debug, error, warn}; + +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); + +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, + /// 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, + /// Whether the banner has been logged (to avoid duplicate log entries on each frame). + logged: bool, +} + +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(); + 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 +/// [`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_message`, `with_auto_dismiss`) take `&self` so the handle can be reused. +#[derive(Clone)] +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 b = banners.iter_mut().find(|b| b.key == self.key)?; + b.text = text.to_string(); + set_banners(&self.ctx, banners); + 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 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); + 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 b = banners.iter_mut().find(|b| b.key == self.key)?; + b.show_elapsed = true; + b.auto_dismiss_after = None; + set_banners(&self.ctx, banners); + 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); + 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::new( + next_banner_key(), + text.to_string(), + message_type, + )); + } + 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_mut().find(|b| b.text == text) { + existing.reset_to(text.to_string(), message_type); + let key = existing.key; + set_banners(ctx, banners); + return BannerHandle { + ctx: ctx.clone(), + key, + }; + } + let key = next_banner_key(); + if !text.is_empty() { + banners.push(BannerState::new(key, text.to_string(), message_type)); + 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.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::new(key, new_text.to_string(), message_type)); + 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_mut(|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) = &mut 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: &mut 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 + }; + + // Log banner message once on first display + if !state.logged { + state.logged = true; + state.log(); + } + + 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 { + 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>, + 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); + 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), + ); + } + }); + }); + + // 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); + + 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{274C}", // cross mark + MessageType::Warning => "\u{26A0}", // warning sign + 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/contracts_documents/add_contracts_screen.rs b/src/ui/contracts_documents/add_contracts_screen.rs index 991c49d33..449d184b6 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 ae2cb6056..3ae003e61 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 ef6aec35f..b904101a4 100644 --- a/src/ui/dashpay/contact_details.rs +++ b/src/ui/dashpay/contact_details.rs @@ -262,6 +262,7 @@ impl ContactDetailsScreen { 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_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 c1e5a4838..6f72a214f 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 37280e8c5..822d62238 100644 --- a/src/ui/dashpay/contact_requests.rs +++ b/src/ui/dashpay/contact_requests.rs @@ -651,6 +651,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 9f73f8f97..34cc13a76 100644 --- a/src/ui/dashpay/contacts_list.rs +++ b/src/ui/dashpay/contacts_list.rs @@ -547,6 +547,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 4a4f6d218..f3416a09b 100644 --- a/src/ui/dashpay/send_payment.rs +++ b/src/ui/dashpay/send_payment.rs @@ -200,6 +200,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); @@ -664,6 +667,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/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index 61daef78d..fa4501aa3 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,10 @@ 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, + 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, @@ -92,8 +88,9 @@ impl IdentitiesScreen { sort_column: IdentitiesSortColumn::Alias, sort_order: IdentitiesSortOrder::Ascending, use_custom_order: true, - refreshing_status: IdentitiesRefreshingStatus::NotRefreshing, - backend_message: None, + refresh_banner: None, + pending_refresh_count: 0, + total_refresh_count: 0, editing_alias_identity: None, editing_alias_value: String::new(), }; @@ -447,7 +444,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; } @@ -943,11 +940,11 @@ impl IdentitiesScreen { "Failed to delete identity from database: {}", e ); - self.backend_message = Some(( - format!("Failed to remove identity: {}", e), + MessageBanner::set_global( + self.app_context.egui_ctx(), + &format!("Failed to remove identity: {}", e), MessageType::Error, - Utc::now(), - )); + ); } } @@ -1089,22 +1086,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 { @@ -1126,11 +1107,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 matches!(message_type, MessageType::Error | MessageType::Warning) + && let Some(handle) = self.refresh_banner.take() + { + handle.clear(); } - self.backend_message = Some((message.to_string(), message_type, Utc::now())); } fn display_task_result( @@ -1140,18 +1123,29 @@ 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(), - )); + 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, + ); + } } } 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![ ( @@ -1223,54 +1217,27 @@ 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::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(); + 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..cd13a469a 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) { - if message_type == MessageType::Error { - self.error_message = Some(format!("Error topping up identity: {}", message)); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // Banner display is handled globally by AppState; this is only for side-effects. + 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 @@ -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/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/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs index ca3c879aa..782624e72 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; @@ -281,15 +284,23 @@ impl MintTokensScreen { let signing_key = match self.selected_key.clone() { Some(key) => key, None => { - self.error_message = Some("No signing key selected".into()); - self.status = MintTokensStatus::ErrorMessage("No key selected".into()); + self.status = MintTokensStatus::Error; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No signing key selected", + MessageType::Error, + ); return AppAction::None; } }; 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; } @@ -302,8 +313,12 @@ impl MintTokensScreen { ) { Ok(id) => id, 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; } }; @@ -362,11 +377,12 @@ impl MintTokensScreen { } impl ScreenLike for MintTokensScreen { - fn display_message(&mut self, message: &str, message_type: MessageType) { - if let MessageType::Error = message_type { - self.status = MintTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); + fn display_message(&mut self, _message: &str, message_type: MessageType) { + // 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) { @@ -496,7 +512,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); @@ -713,11 +729,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 f67c76d9d..4c5f737a9 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, }; @@ -3243,6 +3244,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); @@ -3555,6 +3557,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); @@ -3681,6 +3684,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/tokens/update_token_config.rs b/src/ui/tokens/update_token_config.rs index f7ab218a3..1de17195f 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 169e56650..f890997f6 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 7e3059cfb..e27300f80 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 91edc9378..d6bf5a002 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1385,6 +1385,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, }; 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..4db984f3c --- /dev/null +++ b/tests/kittest/message_banner.rs @@ -0,0 +1,494 @@ +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{274C}"), + (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()); +} + +/// 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)); +}