Skip to main content

elegance/
flash.rs

1//! Success / error flash feedback.
2//!
3//! A short green or red tint on the widget background that fades back to
4//! the normal colour over ~0.8 s, used to confirm the outcome of a submit.
5//!
6//! # Usage
7//!
8//! After a submit completes, call [`ResponseFlashExt::flash_success`] or
9//! [`ResponseFlashExt::flash_error`] on the widget's [`egui::Response`].
10//! The next frames will render the flash and animate it out.
11//!
12//! ```no_run
13//! # use elegance::{TextInput, ResponseFlashExt};
14//! # egui::__run_test_ui(|ui| {
15//! let mut text = String::new();
16//! let resp = ui.add(TextInput::new(&mut text).id_salt("mix_freq"));
17//! if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
18//!     // Pretend we submitted and got a result.
19//!     let ok: bool = true;
20//!     if ok { resp.flash_success(); } else { resp.flash_error(); }
21//! }
22//! # });
23//! ```
24//!
25//! Currently consumed by [`crate::TextInput`].
26
27use egui::{Color32, Context, Id, Response};
28
29use crate::theme::{mix, Theme};
30
31/// The duration of a flash animation, in seconds.
32pub const FLASH_DURATION: f64 = 0.8;
33
34/// Flash outcome. Controls the colour that tints the widget background.
35#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
36pub enum FlashKind {
37    /// Green tint — submit succeeded.
38    Success,
39    /// Red tint — submit failed.
40    Error,
41}
42
43/// Extension trait that lets you trigger a flash directly on an
44/// [`egui::Response`]. Import this trait (or `use elegance::*`) to bring
45/// the methods into scope.
46pub trait ResponseFlashExt {
47    /// Begin a success flash on the widget this [`Response`] came from.
48    ///
49    /// Call once right after a submit returns `Ok`. Safe to re-call — the
50    /// animation restarts from the beginning.
51    fn flash_success(&self);
52
53    /// Begin an error flash on the widget this [`Response`] came from.
54    fn flash_error(&self);
55
56    /// Immediately clear any flash state on the widget this [`Response`]
57    /// came from. Rarely needed — flashes clear themselves after
58    /// [`FLASH_DURATION`].
59    fn clear_flash(&self);
60}
61
62impl ResponseFlashExt for Response {
63    fn flash_success(&self) {
64        start(&self.ctx, self.id, FlashKind::Success);
65    }
66
67    fn flash_error(&self) {
68        start(&self.ctx, self.id, FlashKind::Error);
69    }
70
71    fn clear_flash(&self) {
72        self.ctx.data_mut(|d| d.remove::<FlashState>(self.id));
73        self.ctx.request_repaint();
74    }
75}
76
77/// Begin a success flash on the widget with the given id. Use when you only
78/// have the widget id and a [`Context`], e.g. when completing an async callback
79/// that returned after the originating [`Response`] went out of scope.
80pub fn flash_success(ctx: &Context, id: Id) {
81    start(ctx, id, FlashKind::Success);
82}
83
84/// Begin an error flash on the widget with the given id. Async counterpart
85/// to [`ResponseFlashExt::flash_error`].
86pub fn flash_error(ctx: &Context, id: Id) {
87    start(ctx, id, FlashKind::Error);
88}
89
90// --- internals --------------------------------------------------------------
91
92#[derive(Clone, Copy)]
93pub(crate) struct FlashState {
94    kind: FlashKind,
95    started_at: f64,
96}
97
98pub(crate) fn start(ctx: &Context, id: Id, kind: FlashKind) {
99    let now = ctx.input(|i| i.time);
100    ctx.data_mut(|d| {
101        d.insert_temp(
102            id,
103            FlashState {
104                kind,
105                started_at: now,
106            },
107        );
108    });
109    ctx.request_repaint();
110}
111
112/// Return the current flash for `id`, if one is active. `progress` is in
113/// `0.0..=1.0` where `0.0` is "just started" and `1.0` is "just about to
114/// finish".
115///
116/// Side effects:
117///  * clears expired flash state
118///  * requests a repaint while a flash is active
119pub(crate) fn active_flash(ctx: &Context, id: Id) -> Option<(FlashKind, f32)> {
120    let state = ctx.data(|d| d.get_temp::<FlashState>(id))?;
121    let now = ctx.input(|i| i.time);
122    let elapsed = now - state.started_at;
123    if !(0.0..FLASH_DURATION).contains(&elapsed) {
124        ctx.data_mut(|d| d.remove::<FlashState>(id));
125        return None;
126    }
127    ctx.request_repaint();
128    Some((state.kind, (elapsed / FLASH_DURATION) as f32))
129}
130
131/// Compute the widget background fill for an input-style widget, given
132/// the normal fill and an optional active flash. Uses quadratic ease-out
133/// so the tint fades quickly at first then lingers slightly.
134pub(crate) fn background_fill(
135    theme: &Theme,
136    base_fill: Color32,
137    flash: Option<(FlashKind, f32)>,
138) -> Color32 {
139    let Some((kind, progress)) = flash else {
140        return base_fill;
141    };
142    // Ease-out: remaining = (1 - t)^2, so intensity drops fast then settles.
143    let remaining = (1.0 - progress).powi(2);
144    // Mix the accent 25% (0x40 alpha) into the base fill — the peak tint
145    // at flash start.
146    const PEAK_MIX: f32 = 0x40 as f32 / 255.0;
147    let accent = match kind {
148        FlashKind::Success => theme.palette.green,
149        FlashKind::Error => theme.palette.red,
150    };
151    mix(base_fill, accent, PEAK_MIX * remaining)
152}