1use frus_core::{Color, Point, Rect, ResolvedTextStyle, Scene, TextStyle};
10use frus_layout::Style;
11
12use crate::interaction::Status;
13use crate::theme::Theme;
14use crate::widget::Widget;
15
16const PAD: f32 = 12.0;
17const ACCENT: f32 = 4.0;
18const ICON_W: f32 = 26.0;
19const ICON_SIZE: f32 = 16.0;
21
22fn title_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
34 over.or(theme.and_then(|t| t.widgets.alert.title_text_style))
35 .unwrap_or_else(|| crate::theme::type_scale(theme).title_medium)
36 .resolved()
37}
38
39fn body_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
42 over.or(theme.and_then(|t| t.widgets.alert.content_text_style))
43 .unwrap_or_else(|| crate::theme::type_scale(theme).body_medium)
44 .resolved()
45}
46
47#[derive(Copy, Clone, Debug, PartialEq, Eq)]
49pub enum AlertKind {
50 Info,
52 Success,
54 Warning,
56 Error,
58}
59
60pub struct Alert {
62 title: Option<String>,
63 text: String,
64 kind: AlertKind,
65 title_text_style: Option<TextStyle>,
66 content_text_style: Option<TextStyle>,
67}
68
69impl Alert {
70 pub fn new(text: impl Into<String>) -> Self {
72 Self {
73 title: None,
74 text: text.into(),
75 kind: AlertKind::Info,
76 title_text_style: None,
77 content_text_style: None,
78 }
79 }
80
81 #[must_use]
83 pub fn title_text_style(mut self, style: TextStyle) -> Self {
84 self.title_text_style = Some(style);
85 self
86 }
87
88 #[must_use]
90 pub fn content_text_style(mut self, style: TextStyle) -> Self {
91 self.content_text_style = Some(style);
92 self
93 }
94
95 fn styles(&self, theme: Option<&Theme>) -> (ResolvedTextStyle, ResolvedTextStyle) {
97 (
98 title_style(self.title_text_style, theme),
99 body_style(self.content_text_style, theme),
100 )
101 }
102
103 pub fn title(mut self, title: impl Into<String>) -> Self {
105 self.title = Some(title.into());
106 self
107 }
108
109 pub fn success(mut self) -> Self {
111 self.kind = AlertKind::Success;
112 self
113 }
114
115 pub fn warning(mut self) -> Self {
117 self.kind = AlertKind::Warning;
118 self
119 }
120
121 pub fn error(mut self) -> Self {
123 self.kind = AlertKind::Error;
124 self
125 }
126
127 fn accent(&self, theme: &Theme) -> Color {
128 match self.kind {
129 AlertKind::Info => theme.primary,
130 AlertKind::Success => Color::rgb8(70, 190, 120),
131 AlertKind::Warning => Color::rgb8(230, 170, 40),
132 AlertKind::Error => Color::rgb8(210, 96, 96),
133 }
134 }
135
136 fn icon(&self) -> &'static str {
137 match self.kind {
138 AlertKind::Info => "i",
139 AlertKind::Success => "✓",
140 AlertKind::Warning => "!",
141 AlertKind::Error => "×",
142 }
143 }
144}
145
146impl<Msg> Widget<Msg> for Alert {
147 fn style(&self) -> Style {
148 Style::default()
151 }
152
153 fn measure(&self, theme: &Theme) -> Option<frus_layout::MeasureFn<'_>> {
154 let text = self.text.clone();
155 let title = self.title.clone();
156 let (title_s, body_s) = self.styles(Some(theme));
157 Some(Box::new(move |max_width, _| {
158 let chrome = ACCENT + ICON_W + PAD; let text_avail = max_width.map(|w| (w - chrome).max(40.0));
160 let body = frus_text::measure_wrapped_resolved(&text, &body_s, text_avail);
161 let title_h = title
162 .as_ref()
163 .map(|_| title_s.line_height() + 4.0)
164 .unwrap_or(0.0);
165 let title_w = title
166 .as_ref()
167 .map(|t| frus_text::measure_resolved(t, &title_s).width)
168 .unwrap_or(0.0);
169 let natural_w = chrome + body.width.max(title_w);
170 frus_core::Size::new(
171 max_width.map_or(natural_w, |w| w.min(natural_w)).ceil(),
172 (PAD * 2.0 + title_h + body.height).ceil(),
173 )
174 }))
175 }
176
177 fn measure_key(&self, theme: &Theme) -> Option<u64> {
178 use std::hash::{Hash, Hasher};
179 let mut hasher = std::collections::hash_map::DefaultHasher::new();
180 self.text.hash(&mut hasher);
181 self.title.hash(&mut hasher);
182 let (title_s, body_s) = self.styles(Some(theme));
185 for style in [title_s, body_s] {
186 style.size.to_bits().hash(&mut hasher);
187 style.weight.to_u16().hash(&mut hasher);
188 style.italic.hash(&mut hasher);
189 style.height.map(f32::to_bits).hash(&mut hasher);
190 style.family.hash(&mut hasher);
191 }
192 Some(hasher.finish())
193 }
194
195 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
196 &[]
197 }
198
199 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
200 let o = status.opacity;
201 let accent = self.accent(theme);
202 scene.draw_rect(
204 bounds,
205 accent.fade(0.12 * o),
206 theme.radius,
207 1.0,
208 accent.fade(0.4 * o),
209 );
210 scene.draw_rect(
212 Rect::new(bounds.x, bounds.y, ACCENT, bounds.height),
213 accent.fade(o),
214 0.0,
215 0.0,
216 Color::TRANSPARENT,
217 );
218 scene.text(
221 Point::new(bounds.x + ACCENT + 7.0, bounds.y + PAD),
222 self.icon().to_string(),
223 &ResolvedTextStyle::exact(ICON_SIZE),
224 accent.fade(o),
225 );
226 let text_x = bounds.x + ACCENT + ICON_W;
227 let wrap_w = (bounds.width - (ACCENT + ICON_W + PAD)).max(40.0);
230 let (title_s, body_s) = self.styles(Some(theme));
231 match &self.title {
232 Some(title) => {
233 scene.text(
234 Point::new(text_x, bounds.y + PAD),
235 title.clone(),
236 &title_s,
237 theme.on_surface.fade(o),
238 );
239 scene.text_wrapped(
240 Point::new(text_x, bounds.y + PAD + title_s.line_height() + 4.0),
241 self.text.clone(),
242 &body_s,
243 theme.muted.fade(o),
244 wrap_w,
245 );
246 }
247 None => {
248 scene.text_wrapped(
249 Point::new(text_x, bounds.y + PAD),
250 self.text.clone(),
251 &body_s,
252 theme.on_surface.fade(o),
253 wrap_w,
254 );
255 }
256 }
257 }
258
259 fn on_click(&self) -> Option<Msg> {
260 None
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use frus_core::Primitive;
268
269 #[test]
270 fn paints_accent_and_text() {
271 let alert = Alert::new("Attention !").title("Alerte").warning();
272 let mut scene = Scene::new();
273 Widget::<()>::paint(
274 &alert,
275 Rect::new(0.0, 0.0, 240.0, 60.0),
276 Status::default(),
277 &Theme::default(),
278 &mut scene,
279 );
280 let warn = Color::rgb8(230, 170, 40);
281 assert!(scene
283 .primitives()
284 .iter()
285 .any(|p| matches!(p, Primitive::Rect { color, .. } if *color == warn)));
286 assert!(scene
288 .primitives()
289 .iter()
290 .any(|p| matches!(p, Primitive::Text { text, .. } if text == "Alerte")));
291 assert!(scene
292 .primitives()
293 .iter()
294 .any(|p| matches!(p, Primitive::Text { text, .. } if text == "Attention !")));
295 }
296
297 #[test]
300 fn message_wraps_to_the_offered_width() {
301 let alert = Alert::new("Press Enter to add a task; swipe from the left edge to go back.")
302 .title("Tip");
303 let theme = Theme::default();
304 let measure = Widget::<()>::measure(&alert, &theme).expect("a measure closure");
305 let free = measure(None, None);
306 let narrow = measure(Some(280.0), None);
307 assert!(
308 narrow.width <= 280.0,
309 "clamped to the offer ({})",
310 narrow.width
311 );
312 assert!(narrow.height > free.height, "wrapped → taller");
313 let other = Alert::new("short");
315 assert_ne!(
316 Widget::<()>::measure_key(&alert, &theme),
317 Widget::<()>::measure_key(&other, &theme)
318 );
319 }
320}