Skip to main content

elegance/
callout.rs

1//! Callout — a full-width inline banner announcing persistent context.
2//!
3//! A [`Callout`] is a strip of chrome that sits inline in a layout to flag
4//! important context to the reader: experimental features, unsaved changes,
5//! failed builds, maintenance windows. Unlike [`Toast`](crate::Toast) it does
6//! not auto-dismiss, and unlike [`FlashKind`](crate::FlashKind) it's a whole
7//! surface rather than a pulse on another widget.
8//!
9//! Two visual treatments are available. The default is a `card`-colored
10//! banner with a 3px accent stripe on the leading edge — quiet enough to
11//! read as inline page chrome. Calling [`Callout::tinted`] swaps that for a
12//! severity-tinted background with a matching tinted border and rounded
13//! corners; this reads louder and works well when the banner needs to feel
14//! like a discrete alert rather than part of the surrounding card.
15
16use egui::{
17    Align, Color32, CornerRadius, InnerResponse, Layout, Margin, Rect, Response, Sense, Stroke,
18    StrokeKind, Ui, Vec2, WidgetInfo, WidgetText, WidgetType,
19};
20
21use crate::theme::Theme;
22
23/// Semantic tones for a [`Callout`].
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
25pub enum CalloutTone {
26    /// Informational — sky accent. For neutral announcements.
27    Info,
28    /// Success — green accent. For affirmative context ("Deploy complete").
29    Success,
30    /// Caution — amber accent. For conditions the user should notice.
31    Warning,
32    /// Error or destructive — red accent. For failures or dangerous state.
33    Danger,
34    /// Grey — for announcements that have no urgency at all.
35    Neutral,
36}
37
38impl CalloutTone {
39    fn stripe(self, theme: &Theme) -> Color32 {
40        let p = &theme.palette;
41        match self {
42            Self::Info => p.sky,
43            Self::Success => p.green,
44            Self::Warning => p.amber,
45            Self::Danger => p.red,
46            Self::Neutral => p.text_muted,
47        }
48    }
49
50    fn icon_color(self, theme: &Theme) -> Color32 {
51        let p = &theme.palette;
52        match self {
53            Self::Info => p.sky,
54            Self::Success => p.success,
55            Self::Warning => p.warning,
56            Self::Danger => p.danger,
57            Self::Neutral => p.text_muted,
58        }
59    }
60
61    fn default_icon(self) -> &'static str {
62        match self {
63            Self::Info => "ℹ",
64            Self::Success => "✓",
65            Self::Warning => "⚠",
66            Self::Danger => "×",
67            Self::Neutral => "•",
68        }
69    }
70}
71
72/// A full-width inline banner in the elegance style.
73///
74/// ```no_run
75/// # use elegance::{Callout, CalloutTone};
76/// # egui::__run_test_ui(|ui| {
77/// Callout::new(CalloutTone::Warning)
78///     .title("Unsaved changes.")
79///     .body("You have 3 edits that haven't been written to disk.")
80///     .show(ui, |_| {});
81/// # });
82/// ```
83#[must_use = "Call `.show(ui, ...)` to render the callout."]
84pub struct Callout<'a> {
85    tone: CalloutTone,
86    tinted: bool,
87    title: Option<WidgetText>,
88    body: Option<WidgetText>,
89    icon: Option<WidgetText>,
90    open: Option<&'a mut bool>,
91}
92
93impl std::fmt::Debug for Callout<'_> {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        f.debug_struct("Callout")
96            .field("tone", &self.tone)
97            .field("tinted", &self.tinted)
98            .field("title", &self.title.as_ref().map(|t| t.text()))
99            .field("body", &self.body.as_ref().map(|b| b.text()))
100            .field("icon", &self.icon.as_ref().map(|i| i.text()))
101            .field("dismissable", &self.open.is_some())
102            .finish()
103    }
104}
105
106impl<'a> Callout<'a> {
107    /// Create a new callout with the given tone.
108    pub fn new(tone: CalloutTone) -> Self {
109        Self {
110            tone,
111            tinted: false,
112            title: None,
113            body: None,
114            icon: None,
115            open: None,
116        }
117    }
118
119    /// Set the bolded title text rendered inline at the left of the banner.
120    #[inline]
121    pub fn title(mut self, text: impl Into<WidgetText>) -> Self {
122        self.title = Some(text.into());
123        self
124    }
125
126    /// Set the muted body text, rendered to the right of the title.
127    #[inline]
128    pub fn body(mut self, text: impl Into<WidgetText>) -> Self {
129        self.body = Some(text.into());
130        self
131    }
132
133    /// Override the icon glyph. Defaults to a tone-dependent symbol.
134    #[inline]
135    pub fn icon(mut self, icon: impl Into<WidgetText>) -> Self {
136        self.icon = Some(icon.into());
137        self
138    }
139
140    /// Swap the default stripe-on-card treatment for a severity-tinted
141    /// background with a matching tinted border and rounded corners.
142    ///
143    /// The fill is the tone color at low alpha and the border is the same
144    /// color at moderate alpha; the leading accent stripe is dropped. Use
145    /// this when the banner should read as a discrete alert rather than as
146    /// inline page chrome.
147    #[inline]
148    pub fn tinted(mut self) -> Self {
149        self.tinted = true;
150        self
151    }
152
153    /// Render a trailing × button that sets `*open = false` when clicked.
154    ///
155    /// The caller is responsible for gating the `.show(...)` call on the
156    /// same `bool` so the banner disappears after dismissal.
157    #[inline]
158    pub fn dismissable(mut self, open: &'a mut bool) -> Self {
159        self.open = Some(open);
160        self
161    }
162
163    /// Render the callout and return the closure's result.
164    ///
165    /// `add_actions` is invoked with a **right-to-left** layout so buttons
166    /// you add slot into the action area between the body text and the
167    /// dismiss button. Inside the closure the first widget added appears
168    /// furthest right, so **add your primary action first**:
169    ///
170    /// ```no_run
171    /// # use elegance::{Accent, Button, Callout, CalloutTone};
172    /// # egui::__run_test_ui(|ui| {
173    /// Callout::new(CalloutTone::Warning)
174    ///     .title("Unsaved changes.")
175    ///     .show(ui, |ui| {
176    ///         ui.add(Button::new("Save now").accent(Accent::Amber)); // rightmost
177    ///         ui.add(Button::new("Discard").outline());              // to its left
178    ///     });
179    /// # });
180    /// ```
181    ///
182    /// Pass `|_| {}` when no actions are needed.
183    pub fn show<R>(self, ui: &mut Ui, add_actions: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {
184        const STRIPE_WIDTH: f32 = 3.0;
185        const TINT_RADIUS: u8 = 8;
186        // ~10% / ~28% — matches the bordered-tinted variant in
187        // mockups/banner/banner.html.
188        const TINT_BG_ALPHA: u8 = 26;
189        const TINT_BORDER_ALPHA: u8 = 71;
190
191        let theme = Theme::current(ui.ctx());
192        let p = &theme.palette;
193        let body_size = theme.typography.body;
194        let stripe = self.tone.stripe(&theme);
195        let icon_color = self.tone.icon_color(&theme);
196        let default_icon = self.tone.default_icon();
197
198        let a11y_label = self
199            .title
200            .as_ref()
201            .or(self.body.as_ref())
202            .map(|w| w.text().to_string())
203            .unwrap_or_else(|| "callout".to_string());
204
205        let Self {
206            tone: _,
207            tinted,
208            title,
209            body,
210            icon,
211            open,
212        } = self;
213
214        let frame = if tinted {
215            let bg =
216                Color32::from_rgba_unmultiplied(stripe.r(), stripe.g(), stripe.b(), TINT_BG_ALPHA);
217            let border = Color32::from_rgba_unmultiplied(
218                stripe.r(),
219                stripe.g(),
220                stripe.b(),
221                TINT_BORDER_ALPHA,
222            );
223            egui::Frame::new()
224                .fill(bg)
225                .stroke(Stroke::new(1.0, border))
226                .corner_radius(CornerRadius::same(TINT_RADIUS))
227                .inner_margin(Margin {
228                    left: 14,
229                    right: 14,
230                    top: 10,
231                    bottom: 10,
232                })
233        } else {
234            // Left inner margin accounts for the 3px stripe (24 pt from
235            // stripe to content, matching the HTML mockups).
236            egui::Frame::new().fill(p.card).inner_margin(Margin {
237                left: (STRIPE_WIDTH as i8) + 18,
238                right: 16,
239                top: 10,
240                bottom: 10,
241            })
242        };
243
244        let frame_response: InnerResponse<R> = frame.show(ui, |ui| {
245            ui.horizontal(|ui| {
246                ui.spacing_mut().item_spacing.x = 10.0;
247
248                // Icon.
249                let icon_str = icon
250                    .as_ref()
251                    .map(|w| w.text().to_string())
252                    .unwrap_or_else(|| default_icon.to_string());
253                ui.add(
254                    egui::Label::new(
255                        egui::RichText::new(icon_str)
256                            .color(icon_color)
257                            .size(body_size + 1.0),
258                    )
259                    .wrap_mode(egui::TextWrapMode::Extend),
260                );
261
262                // Title (strong).
263                if let Some(title) = title {
264                    ui.add(
265                        egui::Label::new(
266                            egui::RichText::new(title.text())
267                                .color(p.text)
268                                .size(body_size)
269                                .strong(),
270                        )
271                        .wrap_mode(egui::TextWrapMode::Extend),
272                    );
273                }
274
275                // Body (muted).
276                if let Some(body) = body {
277                    ui.add(
278                        egui::Label::new(
279                            egui::RichText::new(body.text())
280                                .color(p.text_muted)
281                                .size(body_size),
282                        )
283                        .wrap_mode(egui::TextWrapMode::Truncate),
284                    );
285                }
286
287                // Right-aligned action slot and optional dismiss button.
288                ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
289                    if let Some(open) = open {
290                        if dismiss_button(ui, &theme).clicked() {
291                            *open = false;
292                        }
293                    }
294                    add_actions(ui)
295                })
296                .inner
297            })
298            .inner
299        });
300
301        // Stripe + bottom hairline are only for the default treatment;
302        // tinted draws its own border via the frame stroke.
303        if !tinted {
304            let rect = frame_response.response.rect;
305            let painter = ui.painter();
306            painter.rect(
307                Rect::from_min_max(
308                    rect.left_top(),
309                    egui::pos2(rect.left() + STRIPE_WIDTH, rect.bottom()),
310                ),
311                CornerRadius::ZERO,
312                stripe,
313                Stroke::NONE,
314                StrokeKind::Inside,
315            );
316            painter.hline(rect.x_range(), rect.bottom(), Stroke::new(1.0, p.border));
317        }
318
319        frame_response
320            .response
321            .widget_info(|| WidgetInfo::labeled(WidgetType::Other, true, &a11y_label));
322
323        frame_response
324    }
325}
326
327/// Compact × dismiss button. Renders as two diagonal strokes so it doesn't
328/// depend on the presence of a specific "×" glyph in the active font.
329fn dismiss_button(ui: &mut Ui, theme: &Theme) -> Response {
330    let size = Vec2::splat(theme.typography.body + 8.0);
331    let (rect, response) = ui.allocate_exact_size(size, Sense::click());
332    if ui.is_rect_visible(rect) {
333        let color = if response.hovered() || response.has_focus() {
334            theme.palette.text
335        } else {
336            theme.palette.text_faint
337        };
338        let painter = ui.painter();
339        let c = rect.center();
340        let r = 4.5;
341        let stroke = Stroke::new(1.5, color);
342        painter.line_segment([c + Vec2::new(-r, -r), c + Vec2::new(r, r)], stroke);
343        painter.line_segment([c + Vec2::new(r, -r), c + Vec2::new(-r, r)], stroke);
344    }
345    response.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, "Dismiss"));
346    response
347}