Skip to content
Merged
Show file tree
Hide file tree
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 Feb 17, 2026
5445cd1
Merge remote-tracking branch 'origin/v1.0-dev' into design/unified-me…
lklimek Feb 18, 2026
7e16713
feat: add MessageType::Warning
lklimek Feb 18, 2026
75f09b5
chore: initial implementation
lklimek Feb 18, 2026
0dbed4a
chore: docs
lklimek Feb 18, 2026
54a9e27
chore: self review
lklimek Feb 18, 2026
343f411
refactor(context): replace RwLock<Sdk> with ArcSwap<Sdk> (#600)
thepastaclaw Feb 18, 2026
d9ddbfd
refactor: remove unused Insight API and show_in_ui config fields (#597)
lklimek Feb 18, 2026
b7bc968
build: add Flatpak packaging and CI workflow (#589)
lklimek Feb 18, 2026
9bc8ff7
chore: fix build
lklimek Feb 18, 2026
2a79d24
Merge remote-tracking branch 'origin/v1.0-dev' into design/unified-me…
lklimek Feb 18, 2026
eac9bf1
Update src/app.rs
lklimek Feb 18, 2026
8f80a07
chore: peer review
lklimek Feb 18, 2026
c04dd50
feat: add collapsible details and suggestion support to MessageBanner
lklimek Feb 19, 2026
d9f7ce6
fix: correct MessageBanner dedup, replace_global reset, and doc drift
lklimek Feb 19, 2026
a917626
Merge remote-tracking branch 'origin/v1.0-dev' into design/unified-me…
lklimek Feb 20, 2026
8d9fbc1
feat: log everything that is displayed by the MessageBanner
lklimek Feb 20, 2026
ab18298
fix(ui): address PR #601 review items 12-16 for MessageBanner
lklimek Feb 23, 2026
d0e6d6e
fix(ui): clarify display_message contract for Success/Info in MintTok…
lklimek Feb 23, 2026
83f13e8
fix(ui): use distinct icon for Error vs Warning banners
lklimek Feb 23, 2026
cfcf039
fix(test): update banner test to expect new Error icon
lklimek Feb 23, 2026
4876892
Update docs/ai-design/2026-02-17-unified-messages/architecture.md
lklimek Feb 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Connection>`. Schema initialized in `database/initialization.rs`. Domain modules provide typed CRUD methods. Backend task errors are `Result<T, String>` — string errors display directly to users.
Expand Down
24 changes: 24 additions & 0 deletions doc/COMPONENT_DESIGN_PATTERN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
257 changes: 257 additions & 0 deletions docs/ai-design/2026-02-17-unified-messages/architecture.md
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
```

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`

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.
Loading
Loading