1use alloc::string::{String, ToString};
4
5use denise::Pen;
6use denise::{Point, Radius, Role};
7use denise_text::{TextEngine, TextStyle};
8
9use crate::widget::{MeasureCtx, Measured, Offer, PaintCtx, Widget};
10use crate::widgets::describe::{
11 Describe, DynDescribe, Group, Mismatch, Property, PropertyKind, ROLES, Value,
12};
13use crate::widgets::style::interactive_pair;
14
15#[derive(Clone, Debug)]
52pub struct Alert {
53 text: String,
54 icon: Option<char>,
55 role: Role,
56 style: TextStyle,
57}
58
59impl Alert {
60 pub fn new(role: Role, text: impl Into<String>) -> Self {
64 Self {
65 text: text.into(),
66 icon: None,
67 role,
68 style: TextStyle::built_in(16),
69 }
70 }
71
72 pub fn with_icon(mut self, icon: char) -> Self {
79 self.icon = Some(icon);
80 self
81 }
82
83 pub fn with_style(mut self, style: TextStyle) -> Self {
85 self.style = style;
86 self
87 }
88
89 #[inline]
91 pub fn text(&self) -> &str {
92 &self.text
93 }
94
95 pub fn set_text(&mut self, text: impl Into<String>) {
97 self.text = text.into();
98 }
99
100 pub fn update(&mut self, text: &str) -> bool {
102 let changed = self.text != text;
103 if changed {
104 self.text = text.to_string();
105 }
106 changed
107 }
108
109 pub fn set_role(&mut self, role: Role) {
111 self.role = role;
112 }
113
114 #[inline]
116 pub const fn role(&self) -> Role {
117 self.role
118 }
119
120 pub fn set_icon(&mut self, icon: Option<char>) {
122 self.icon = icon;
123 }
124
125 pub fn preferred_height(&self, engine: &mut TextEngine, width: i32) -> i32 {
127 let inset = padding(self.style.size_px);
128 let available = self.text_width(engine, width);
130 engine.wrapped_height(self.style, &self.text, available) + inset * 2
131 }
132
133 fn text_width(&self, engine: &mut TextEngine, width: i32) -> i32 {
135 let inset = padding(self.style.size_px);
136 let icon = self.icon_width(engine);
137 (width - inset * 2 - icon).max(1)
138 }
139
140 fn icon_width(&self, engine: &mut TextEngine) -> i32 {
142 let Some(icon) = self.icon else {
143 return 0;
144 };
145 let mut buffer = [0u8; 4];
146 let glyph = icon.encode_utf8(&mut buffer);
147 engine.measure_line(self.style, glyph) + padding(self.style.size_px)
148 }
149}
150
151#[inline]
153const fn padding(size_px: u16) -> i32 {
154 let half = size_px as i32 / 2;
155 if half < 4 { 4 } else { half }
156}
157
158impl<M: 'static> Widget<M> for Alert {
159 fn describe(&self) -> Option<&dyn DynDescribe> {
160 Some(self)
161 }
162
163 fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
164 Some(self)
165 }
166 fn measure(&self, ctx: &mut MeasureCtx<'_>, offered: Offer) -> Measured {
167 Measured {
171 width: None,
172 height: offered.width.map(|w| self.preferred_height(ctx.text, w)),
173 }
174 }
175
176 fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
177 let bounds = ctx.bounds;
178 if bounds.is_empty() {
179 return;
180 }
181 let (fill, content) = interactive_pair(ctx.theme, self.role, ctx.state);
186 canvas.fill_rounded_rect(bounds, ctx.theme.radius(Radius::Box), fill);
187
188 let inset = padding(self.style.size_px);
189 let line_height = ctx.text.line_height(self.style);
190 let mut x = bounds.x + inset;
191
192 if let Some(icon) = self.icon {
193 let mut buffer = [0u8; 4];
194 let glyph = icon.encode_utf8(&mut buffer);
195 let width = ctx.text.measure_line(self.style, glyph);
196 ctx.text.draw(
197 canvas,
198 self.style,
199 Point::new(x, bounds.y + inset),
200 glyph,
201 content,
202 );
203 x += width + inset;
204 }
205
206 let available = (bounds.right() - inset - x).max(1);
207 let lines: alloc::vec::Vec<&str> = ctx.text.wrap(self.style, &self.text, available);
210 for (index, line) in lines.iter().enumerate() {
211 let y = bounds.y + inset + index as i32 * line_height;
212 if y >= bounds.bottom() {
213 break;
217 }
218 ctx.text
219 .draw(canvas, self.style, Point::new(x, y), line, content);
220 }
221 }
222}
223
224impl Describe for Alert {
225 const KIND: &'static str = "alert";
226 const DOC: &'static str =
227 "A coloured banner saying something happened, in the place it happened.";
228 const GROUP: Group = Group::Display;
229 const ICON: &'static denise::icon::Icon = &super::icons::ALERT;
230
231 const PROPERTIES: &'static [Property] = &[
232 Property::new("text", PropertyKind::Text, "The message."),
233 Property::new(
234 "role",
235 PropertyKind::Enum(ROLES),
236 "The status this banner reports; an alert with no status is a label.",
237 ),
238 Property::new(
239 "icon",
240 PropertyKind::Text,
241 "A single character drawn before the text.",
242 ),
243 Property::new(
244 "size",
245 PropertyKind::Int { min: 6, max: 96 },
246 "Text size in logical pixels.",
247 )
248 .in_pixels(),
249 ];
250
251 fn get(&self, name: &str) -> Option<Value> {
252 Some(match name {
253 "text" => Value::text(self.text.as_str()),
254 "role" => Value::role(self.role),
255 "icon" => Value::Text(self.icon?.to_string()),
258 "size" => Value::Int(i32::from(self.style.size_px)),
259 _ => return None,
260 })
261 }
262
263 fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
264 match name {
265 "text" => self.text = value.as_text()?,
266 "role" => self.role = value.as_role()?,
267 "icon" => self.icon = value.as_text()?.chars().next(),
273 "size" => self.style.size_px = value.as_size()?,
274 _ => return Err(Mismatch::Unknown),
275 }
276 Ok(())
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 use denise::Theme;
284
285 use crate::widget::VisualState;
286
287 fn engine() -> TextEngine {
288 TextEngine::new()
289 }
290
291 #[test]
295 fn a_longer_message_needs_a_taller_banner_at_the_same_width() {
296 let mut engine = engine();
297 let short = Alert::new(Role::Info, "Lagret").preferred_height(&mut engine, 200);
298 let long = Alert::new(
299 Role::Error,
300 "Kunne ikke lagre fordi disken er full og det er ingen plass igjen",
301 )
302 .preferred_height(&mut engine, 200);
303 assert!(long > short, "{long} is not taller than {short}");
304 }
305
306 #[test]
308 fn a_wider_banner_needs_less_height_for_the_same_message() {
309 let mut engine = engine();
310 let alert = Alert::new(Role::Warning, "en to tre fire fem seks sju atte ni ti");
311 let narrow = alert.preferred_height(&mut engine, 120);
312 let wide = alert.preferred_height(&mut engine, 600);
313 assert!(narrow > wide, "narrow {narrow} should exceed wide {wide}");
314 }
315
316 #[test]
319 fn an_icon_takes_its_space_from_the_message() {
320 let mut engine = engine();
321 let text = "en to tre fire fem seks sju atte";
322 let bare = Alert::new(Role::Info, text).preferred_height(&mut engine, 160);
323 let iconed = Alert::new(Role::Info, text)
324 .with_icon('!')
325 .preferred_height(&mut engine, 160);
326 assert!(
327 iconed >= bare,
328 "an icon should not make the banner shorter: {iconed} < {bare}"
329 );
330 assert!(Alert::new(Role::Info, text).icon_width(&mut engine) == 0);
331 assert!(
332 Alert::new(Role::Info, text)
333 .with_icon('!')
334 .icon_width(&mut engine)
335 > 0
336 );
337 }
338
339 #[test]
342 fn an_absurdly_narrow_banner_still_leaves_a_column_for_the_text() {
343 let mut engine = engine();
344 for width in [-100, 0, 1, 5, 20] {
345 let alert = Alert::new(Role::Error, "feil").with_icon('!');
346 assert!(
347 alert.text_width(&mut engine, width) >= 1,
348 "width {width} left no room at all"
349 );
350 assert!(alert.preferred_height(&mut engine, width) > 0);
351 }
352 }
353
354 #[test]
357 fn an_empty_message_still_has_height() {
358 let mut engine = engine();
359 let height = Alert::new(Role::Info, "").preferred_height(&mut engine, 200);
360 assert!(height > 0);
361 }
362
363 #[test]
365 fn writing_the_same_message_reports_no_change() {
366 let mut alert = Alert::new(Role::Info, "Lagret");
367 assert!(!alert.update("Lagret"));
368 assert!(alert.update("Lagret kl. 12:01"));
369 }
370
371 #[test]
375 fn every_role_keeps_its_message_readable_in_every_theme() {
376 use denise::theme::{AA_LARGE, contrast_x100};
377
378 for theme in Theme::BUILT_IN {
379 for role in [Role::Info, Role::Success, Role::Warning, Role::Error] {
380 for state in [VisualState::NONE, VisualState::DISABLED] {
381 let (fill, content) = interactive_pair(&theme, role, state);
382 let ratio = contrast_x100(fill, content);
383 assert!(
384 ratio >= AA_LARGE,
385 "{} {role:?} {state:?}: message on banner is {ratio}, floor \
386 is {AA_LARGE}",
387 theme.name
388 );
389 }
390 }
391 }
392 }
393
394 #[test]
396 fn a_multi_byte_icon_survives_being_measured() {
397 let mut engine = engine();
398 for icon in ['!', 'æ', '✓', '⚠'] {
399 let alert = Alert::new(Role::Info, "melding").with_icon(icon);
400 assert!(
401 alert.icon_width(&mut engine) > 0,
402 "{icon} measured as nothing"
403 );
404 }
405 }
406}