1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
23pub enum CalloutTone {
24 Info,
26 Success,
28 Warning,
30 Danger,
32 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#[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 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 #[inline]
116 pub fn title(mut self, text: impl Into<WidgetText>) -> Self {
117 self.title = Some(text.into());
118 self
119 }
120
121 #[inline]
123 pub fn body(mut self, text: impl Into<WidgetText>) -> Self {
124 self.body = Some(text.into());
125 self
126 }
127
128 #[inline]
130 pub fn icon(mut self, icon: impl Into<WidgetText>) -> Self {
131 self.icon = Some(icon.into());
132 self
133 }
134
135 #[inline]
140 pub fn dismissable(mut self, open: &'a mut bool) -> Self {
141 self.open = Some(open);
142 self
143 }
144
145 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 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 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 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 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 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 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
279fn 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}