1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
//! [`Alert`]: a **persistent** (contextual) message box, as opposed to the transient
//! [`crate::SnackBar`].
//!
//! It was called `AlertDialog` until milestone 424, and it never was one: it has no
//! actions, no barrier and no message type, and it sits in the flow rather than over it.
//! The reference's `AlertDialog` is [`crate::AlertDialog`] now, and this kept the shape it
//! always had under a name that describes it.
use frus_core::{Color, Point, Rect, ResolvedTextStyle, Scene, TextStyle};
use frus_layout::Style;
use crate::interaction::Status;
use crate::theme::Theme;
use crate::widget::Widget;
const PAD: f32 = 12.0;
const ACCENT: f32 = 4.0;
const ICON_W: f32 = 26.0;
/// The icon glyph's size. **Geometry, not type**: it sits in a column of a fixed `ICON_W`.
const ICON_SIZE: f32 = 16.0;
/// The title's style: what the caller said, else what the theme says, else `titleMedium`.
///
/// The reference's *dialog* titles in `headlineSmall`, and this is not that dialog —
/// [`crate::AlertDialog`] is, since milestone 424. This has the shape of the reference's
/// banner: an accent bar, an icon and a tinted background, no actions and no barrier. The
/// banner has no title at all, so the heading role at this scale is the nearest thing the
/// reference says — a 24 px heading inside a 12 px box would not be.
///
/// **Resolved once**: the same number measures the box and draws the glyphs. Resolving is
/// where the reader's font setting is applied, so measuring at the bare constant and
/// painting through a style is a layout that disagrees with itself.
fn title_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
over.or(theme.and_then(|t| t.widgets.alert.title_text_style))
.unwrap_or_else(|| crate::theme::type_scale(theme).title_medium)
.resolved()
}
/// The message's style — the reference's banner *and* its dialog both say `bodyMedium`.
/// See [`title_style`].
fn body_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
over.or(theme.and_then(|t| t.widgets.alert.content_text_style))
.unwrap_or_else(|| crate::theme::type_scale(theme).body_medium)
.resolved()
}
/// The nature of an alert box.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum AlertKind {
/// Neutral information.
Info,
/// Something went well.
Success,
/// Something that deserves attention, short of an error.
Warning,
/// Something went wrong.
Error,
}
/// A message box.
pub struct Alert {
title: Option<String>,
text: String,
kind: AlertKind,
title_text_style: Option<TextStyle>,
content_text_style: Option<TextStyle>,
}
impl Alert {
/// Creates an informational box.
pub fn new(text: impl Into<String>) -> Self {
Self {
title: None,
text: text.into(),
kind: AlertKind::Info,
title_text_style: None,
content_text_style: None,
}
}
/// The heading's type, over the theme's and the reference's.
#[must_use]
pub fn title_text_style(mut self, style: TextStyle) -> Self {
self.title_text_style = Some(style);
self
}
/// The message's type, over the theme's and the reference's.
#[must_use]
pub fn content_text_style(mut self, style: TextStyle) -> Self {
self.content_text_style = Some(style);
self
}
/// The two styles this box is measured and drawn with.
fn styles(&self, theme: Option<&Theme>) -> (ResolvedTextStyle, ResolvedTextStyle) {
(
title_style(self.title_text_style, theme),
body_style(self.content_text_style, theme),
)
}
/// Adds a title.
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
/// The success variant.
pub fn success(mut self) -> Self {
self.kind = AlertKind::Success;
self
}
/// Variante avertissement.
pub fn warning(mut self) -> Self {
self.kind = AlertKind::Warning;
self
}
/// Variante erreur.
pub fn error(mut self) -> Self {
self.kind = AlertKind::Error;
self
}
fn accent(&self, theme: &Theme) -> Color {
match self.kind {
AlertKind::Info => theme.primary,
AlertKind::Success => Color::rgb8(70, 190, 120),
AlertKind::Warning => Color::rgb8(230, 170, 40),
AlertKind::Error => Color::rgb8(210, 96, 96),
}
}
fn icon(&self) -> &'static str {
match self.kind {
AlertKind::Info => "i",
AlertKind::Success => "✓",
AlertKind::Warning => "!",
AlertKind::Error => "×",
}
}
}
impl<Msg> Widget<Msg> for Alert {
fn style(&self) -> Style {
// A paragraph: free dimensions, the size comes from `measure()` — the
// message wraps to the width the parent offers.
Style::default()
}
fn measure(&self, theme: &Theme) -> Option<frus_layout::MeasureFn<'_>> {
let text = self.text.clone();
let title = self.title.clone();
let (title_s, body_s) = self.styles(Some(theme));
Some(Box::new(move |max_width, _| {
let chrome = ACCENT + ICON_W + PAD; // bar + icon + right margin
let text_avail = max_width.map(|w| (w - chrome).max(40.0));
let body = frus_text::measure_wrapped_resolved(&text, &body_s, text_avail);
let title_h = title
.as_ref()
.map(|_| title_s.line_height() + 4.0)
.unwrap_or(0.0);
let title_w = title
.as_ref()
.map(|t| frus_text::measure_resolved(t, &title_s).width)
.unwrap_or(0.0);
let natural_w = chrome + body.width.max(title_w);
frus_core::Size::new(
max_width.map_or(natural_w, |w| w.min(natural_w)).ceil(),
(PAD * 2.0 + title_h + body.height).ceil(),
)
}))
}
fn measure_key(&self, theme: &Theme) -> Option<u64> {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
self.text.hash(&mut hasher);
self.title.hash(&mut hasher);
// The **resolved** styles, because the theme now has a say in the measurement and
// a key that ignores half its inputs is a stale layout waiting for a theme swap.
let (title_s, body_s) = self.styles(Some(theme));
for style in [title_s, body_s] {
style.size.to_bits().hash(&mut hasher);
style.weight.to_u16().hash(&mut hasher);
style.italic.hash(&mut hasher);
style.height.map(f32::to_bits).hash(&mut hasher);
style.family.hash(&mut hasher);
}
Some(hasher.finish())
}
fn children(&self) -> &[Box<dyn Widget<Msg>>] {
&[]
}
fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
let o = status.opacity;
let accent = self.accent(theme);
// Tinted background + a discreet border.
scene.draw_rect(
bounds,
accent.fade(0.12 * o),
theme.radius,
1.0,
accent.fade(0.4 * o),
);
// Accent bar on the left.
scene.draw_rect(
Rect::new(bounds.x, bounds.y, ACCENT, bounds.height),
accent.fade(o),
0.0,
0.0,
Color::TRANSPARENT,
);
// The glyph standing in for an icon. `exact`, not resolved: it sits in a column
// of a fixed `ICON_W`, and a reader who doubles the type would push it out.
scene.text(
Point::new(bounds.x + ACCENT + 7.0, bounds.y + PAD),
self.icon().to_string(),
&ResolvedTextStyle::exact(ICON_SIZE),
accent.fade(o),
);
let text_x = bounds.x + ACCENT + ICON_W;
// The message wraps to the width actually available (the laid-out one),
// to stay consistent with `measure()`.
let wrap_w = (bounds.width - (ACCENT + ICON_W + PAD)).max(40.0);
let (title_s, body_s) = self.styles(Some(theme));
match &self.title {
Some(title) => {
scene.text(
Point::new(text_x, bounds.y + PAD),
title.clone(),
&title_s,
theme.on_surface.fade(o),
);
scene.text_wrapped(
Point::new(text_x, bounds.y + PAD + title_s.line_height() + 4.0),
self.text.clone(),
&body_s,
theme.muted.fade(o),
wrap_w,
);
}
None => {
scene.text_wrapped(
Point::new(text_x, bounds.y + PAD),
self.text.clone(),
&body_s,
theme.on_surface.fade(o),
wrap_w,
);
}
}
}
fn on_click(&self) -> Option<Msg> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use frus_core::Primitive;
#[test]
fn paints_accent_and_text() {
let alert = Alert::new("Attention !").title("Alerte").warning();
let mut scene = Scene::new();
Widget::<()>::paint(
&alert,
Rect::new(0.0, 0.0, 240.0, 60.0),
Status::default(),
&Theme::default(),
&mut scene,
);
let warn = Color::rgb8(230, 170, 40);
// The accent bar (the variant's solid color) is present.
assert!(scene
.primitives()
.iter()
.any(|p| matches!(p, Primitive::Rect { color, .. } if *color == warn)));
// Title + text are painted.
assert!(scene
.primitives()
.iter()
.any(|p| matches!(p, Primitive::Text { text, .. } if text == "Alerte")));
assert!(scene
.primitives()
.iter()
.any(|p| matches!(p, Primitive::Text { text, .. } if text == "Attention !")));
}
/// The message **wraps** to the offered width: narrower → taller (and never
/// wider than the offer) — no more box overflowing its parent.
#[test]
fn message_wraps_to_the_offered_width() {
let alert = Alert::new("Press Enter to add a task; swipe from the left edge to go back.")
.title("Tip");
let theme = Theme::default();
let measure = Widget::<()>::measure(&alert, &theme).expect("a measure closure");
let free = measure(None, None);
let narrow = measure(Some(280.0), None);
assert!(
narrow.width <= 280.0,
"clamped to the offer ({})",
narrow.width
);
assert!(narrow.height > free.height, "wrapped → taller");
// The measure key follows the content (the relayout cache).
let other = Alert::new("short");
assert_ne!(
Widget::<()>::measure_key(&alert, &theme),
Widget::<()>::measure_key(&other, &theme)
);
}
}