-
Notifications
You must be signed in to change notification settings - Fork 10
feat(ui): unified MessageBanner component for screen-level messages #601
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
f948c9b
docs: add unified message display design documents
lklimek 5445cd1
Merge remote-tracking branch 'origin/v1.0-dev' into design/unified-me…
lklimek 7e16713
feat: add MessageType::Warning
lklimek 75f09b5
chore: initial implementation
lklimek 0dbed4a
chore: docs
lklimek 54a9e27
chore: self review
lklimek 343f411
refactor(context): replace RwLock<Sdk> with ArcSwap<Sdk> (#600)
thepastaclaw d9ddbfd
refactor: remove unused Insight API and show_in_ui config fields (#597)
lklimek b7bc968
build: add Flatpak packaging and CI workflow (#589)
lklimek 9bc8ff7
chore: fix build
lklimek 2a79d24
Merge remote-tracking branch 'origin/v1.0-dev' into design/unified-me…
lklimek eac9bf1
Update src/app.rs
lklimek 8f80a07
chore: peer review
lklimek c04dd50
feat: add collapsible details and suggestion support to MessageBanner
lklimek d9f7ce6
fix: correct MessageBanner dedup, replace_global reset, and doc drift
lklimek a917626
Merge remote-tracking branch 'origin/v1.0-dev' into design/unified-me…
lklimek 8d9fbc1
feat: log everything that is displayed by the MessageBanner
lklimek ab18298
fix(ui): address PR #601 review items 12-16 for MessageBanner
lklimek d0e6d6e
fix(ui): clarify display_message contract for Success/Info in MintTok…
lklimek 83f13e8
fix(ui): use distinct icon for Error vs Warning banners
lklimek cfcf039
fix(test): update banner test to expect new Error icon
lklimek 4876892
Update docs/ai-design/2026-02-17-unified-messages/architecture.md
lklimek File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
257 changes: 257 additions & 0 deletions
257
docs/ai-design/2026-02-17-unified-messages/architecture.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Duration>, // None = persistent, Some = countdown | ||
| show_elapsed: bool, // Show elapsed time instead of countdown | ||
| details: Option<String>, // Technical details (collapsible section) | ||
| suggestion: Option<String>, // 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<RwLock<...>>, 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<Duration>` | 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<BannerState>` 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<BannerState>, | ||
| } | ||
| ``` | ||
|
|
||
| | 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<BannerStatus>` and private `changed: bool` | ||
| - `show(ui)` renders the banner and returns `InnerResponse<MessageBannerResponse>` | ||
| - `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 | ||
| ``` | ||
lklimek marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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<BannerHandle>, | ||
|
|
||
| // 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<BannerHandle>`) 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` | ||
lklimek marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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. | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.