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