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}