1use std::sync::Arc;
4
5use unicode_width::UnicodeWidthStr;
6
7use crate::core::element::Element;
8use crate::style::{Align, BorderStyle, Length, Padding, Paint, Rect, RichText, Span, Style};
9use crate::widgets::frame::EdgeDecoration;
10use crate::widgets::text::Overflow;
11use crate::widgets::{BorderLabels, Frame, FrameLabel, Text};
12
13pub(crate) const COPY_GLYPH: &str = "⧉";
14
15pub(crate) fn copy_zone_with_right_padding(rect: Rect, right_padding: u16) -> Rect {
16 let glyph_width = UnicodeWidthStr::width(COPY_GLYPH).max(1) as u16;
17 if rect.w <= glyph_width + 1 + right_padding || rect.h == 0 {
18 return Rect {
19 x: rect.x,
20 y: rect.y,
21 w: 0,
22 h: 0,
23 };
24 }
25
26 Rect {
27 x: rect.x + rect.w.saturating_sub(glyph_width + 1 + right_padding) as i16,
28 y: rect.y,
29 w: glyph_width,
30 h: 1,
31 }
32}
33
34#[derive(Clone)]
36pub struct Toast {
37 pub message: Arc<str>,
39 pub duration: f64,
41 pub dismiss_on_click: bool,
43 pub copyable: bool,
45 pub copy_affordance: ToastCopyAffordance,
47 pub(crate) title: Option<Arc<str>>,
48 pub(crate) title_prefix: Option<RichText>,
49 pub(crate) title_suffix: Option<RichText>,
50 pub(crate) title_alignment: Align,
51 pub(crate) title_style: Style,
52 pub(crate) message_style: Style,
53 pub(crate) frame_style: Style,
54 pub(crate) border: bool,
55 pub(crate) border_style: BorderStyle,
56 pub(crate) header_padding: Padding,
57 pub(crate) padding: Padding,
58 pub(crate) decorations: Vec<EdgeDecoration>,
59 pub(crate) width: Length,
60 pub(crate) height: Length,
61 pub(crate) min_width: Option<Length>,
62 pub(crate) max_width: Option<Length>,
63 pub(crate) wrap: bool,
64}
65
66#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68pub enum ToastCopyAffordance {
69 None,
71 BorderGlyph,
73}
74
75impl Toast {
76 pub fn new(message: impl Into<Arc<str>>) -> Self {
78 Self {
79 message: message.into(),
80 duration: 3.0,
81 dismiss_on_click: true,
82 copyable: false,
83 copy_affordance: ToastCopyAffordance::BorderGlyph,
84 title: None,
85 title_prefix: None,
86 title_suffix: None,
87 title_alignment: Align::Start,
88 title_style: Style::default(),
89 message_style: Style::default(),
90 frame_style: Style::default(),
91 border: true,
92 border_style: BorderStyle::Rounded,
93 header_padding: Padding::default(),
94 padding: Padding::default(),
95 decorations: Vec::new(),
96 width: Length::Auto,
97 height: Length::Auto,
98 min_width: None,
99 max_width: None,
100 wrap: true,
101 }
102 }
103
104 pub fn duration(mut self, secs: f64) -> Self {
106 self.duration = secs;
107 self
108 }
109
110 pub fn title(mut self, title: Option<impl Into<Arc<str>>>) -> Self {
112 self.title = title.map(Into::into);
113 self
114 }
115
116 pub fn title_prefix(mut self, prefix: impl Into<RichText>) -> Self {
118 self.title_prefix = Some(prefix.into());
119 self
120 }
121
122 pub fn title_suffix(mut self, suffix: impl Into<RichText>) -> Self {
124 self.title_suffix = Some(suffix.into());
125 self
126 }
127
128 pub fn title_alignment(mut self, align: Align) -> Self {
130 self.title_alignment = align;
131 self
132 }
133
134 pub fn title_style(mut self, style: Style) -> Self {
136 self.title_style = style;
137 self
138 }
139
140 pub fn message_style(mut self, style: Style) -> Self {
142 self.message_style = style;
143 self
144 }
145
146 pub fn frame_style(mut self, style: Style) -> Self {
148 self.frame_style = style;
149 self
150 }
151
152 pub fn border(mut self, border: bool) -> Self {
154 self.border = border;
155 self
156 }
157
158 pub fn border_style(mut self, border_style: BorderStyle) -> Self {
160 self.border_style = border_style;
161 self
162 }
163
164 pub fn header_padding(mut self, padding: impl Into<Padding>) -> Self {
166 self.header_padding = padding.into();
167 self
168 }
169
170 pub fn padding(mut self, padding: impl Into<Padding>) -> Self {
172 self.padding = padding.into();
173 self
174 }
175
176 pub fn decoration(mut self, decoration: EdgeDecoration) -> Self {
178 self.decorations.push(decoration);
179 self
180 }
181
182 pub fn decorations(mut self, decorations: Vec<EdgeDecoration>) -> Self {
184 self.decorations = decorations;
185 self
186 }
187
188 pub fn width(mut self, width: Length) -> Self {
190 self.width = width;
191 self
192 }
193
194 pub fn height(mut self, height: Length) -> Self {
196 self.height = height;
197 self
198 }
199
200 pub fn min_width(mut self, width: Length) -> Self {
202 self.min_width = Some(width);
203 self
204 }
205
206 pub fn max_width(mut self, width: Length) -> Self {
208 self.max_width = Some(width);
209 self
210 }
211
212 pub fn dismiss_on_click(mut self, dismiss: bool) -> Self {
214 self.dismiss_on_click = dismiss;
215 self
216 }
217
218 pub fn copyable(mut self, copyable: bool) -> Self {
224 self.copyable = copyable;
225 self
226 }
227
228 pub fn copy_affordance(mut self, affordance: ToastCopyAffordance) -> Self {
230 self.copy_affordance = affordance;
231 self
232 }
233
234 pub fn wrap(mut self, wrap: bool) -> Self {
236 self.wrap = wrap;
237 self
238 }
239
240 pub fn into_element(self) -> Element {
242 let min_width = self.min_width;
243 let max_width = self.max_width;
244
245 let mut message_style = self.message_style;
251 if message_style.bg.is_none()
252 && !self
253 .frame_style
254 .bg
255 .is_some_and(|bg| matches!(bg, Paint::Alpha { alpha, .. } if alpha < 255))
256 {
257 message_style.bg = self.frame_style.bg;
258 }
259
260 let overflow = if self.wrap {
261 Overflow::Wrap
262 } else {
263 Overflow::Auto
264 };
265
266 let render_copy_affordance = self.copyable
267 && self.border
268 && matches!(self.copy_affordance, ToastCopyAffordance::BorderGlyph);
269 let mut frame = Frame::new()
270 .border(self.border)
271 .border_style(self.border_style)
272 .padding(self.padding)
273 .style(self.frame_style)
274 .child(
275 Text::new(self.message.clone())
276 .style(message_style)
277 .overflow(overflow),
278 )
279 .width(self.width)
280 .height(self.height);
281
282 let title_suffix = if render_copy_affordance {
283 let mut suffix = self.title_suffix.clone().unwrap_or_default();
284 if !suffix.is_empty() {
285 suffix.spans.push(Span::new(" "));
286 }
287 suffix.spans.push(Span::new(COPY_GLYPH));
288 Some(suffix)
289 } else {
290 self.title_suffix.clone()
291 };
292 let mut header = BorderLabels::new()
293 .padding(self.header_padding)
294 .style(self.title_style);
295 let mut title = RichText::new();
296 if let Some(prefix) = self.title_prefix {
297 title.spans.extend(prefix.spans);
298 }
299 if let Some(title_text) = self.title {
300 if !title.is_empty() {
301 title.spans.push(Span::new("─"));
302 }
303 title.spans.push(Span::new(title_text));
304 }
305 if let Some(suffix) = title_suffix {
306 if !title.is_empty() {
307 title.spans.push(Span::new("─"));
308 }
309 title.spans.extend(suffix.spans);
310 }
311 if !title.is_empty() {
312 let label = FrameLabel::new(title);
313 header = match self.title_alignment {
314 Align::Center => header.center(label),
315 Align::End => header.right(label),
316 Align::Start | Align::Stretch => header.left(label),
317 };
318 }
319 if render_copy_affordance {
320 header = header.right(FrameLabel::new(COPY_GLYPH));
321 }
322 frame = frame.header(header);
323 if !self.decorations.is_empty() {
324 frame = frame.decorations(self.decorations.clone());
325 }
326
327 let mut element: Element = frame.into();
328 if let Some(min_width) = min_width {
329 element = element.min_width(min_width);
330 }
331 if let Some(max_width) = max_width {
332 element = element.max_width(max_width);
333 }
334 element
335 }
336}
337
338impl From<Toast> for Element {
339 fn from(toast: Toast) -> Self {
340 toast.into_element()
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use crate::core::element::ElementKind;
348
349 #[test]
350 fn copyable_defaults_to_false() {
351 let toast = Toast::new("message");
352
353 assert!(!toast.copyable);
354 assert_eq!(toast.copy_affordance, ToastCopyAffordance::BorderGlyph);
355 }
356
357 #[test]
358 fn copy_zone_tracks_top_right_header_label_cell() {
359 let zone = copy_zone_with_right_padding(
360 Rect {
361 x: 10,
362 y: 5,
363 w: 20,
364 h: 3,
365 },
366 0,
367 );
368
369 assert_eq!(zone.x, 28);
370 assert_eq!(zone.y, 5);
371 assert_eq!(zone.w, 1);
372 assert_eq!(zone.h, 1);
373 assert!(zone.contains(28, 5));
374 assert!(!zone.contains(29, 5));
375 assert!(!zone.contains(28, 6));
376 }
377
378 #[test]
379 fn copy_zone_accounts_for_header_right_padding() {
380 let zone = copy_zone_with_right_padding(
381 Rect {
382 x: 10,
383 y: 5,
384 w: 20,
385 h: 3,
386 },
387 2,
388 );
389
390 assert_eq!(zone.x, 26);
391 assert_eq!(zone.y, 5);
392 assert_eq!(zone.w, 1);
393 assert_eq!(zone.h, 1);
394 }
395
396 #[test]
397 fn copyable_borderless_toast_does_not_render_hidden_copy_suffix() {
398 let element = Toast::new("message")
399 .copyable(true)
400 .border(false)
401 .into_element();
402
403 let ElementKind::Frame(frame) = element.kind else {
404 panic!("toast should lower to a frame");
405 };
406 assert!(!frame.props.header.has_labels());
407 }
408
409 #[test]
410 fn copy_affordance_none_does_not_render_copy_suffix() {
411 let element = Toast::new("message")
412 .copyable(true)
413 .copy_affordance(ToastCopyAffordance::None)
414 .into_element();
415
416 let ElementKind::Frame(frame) = element.kind else {
417 panic!("toast should lower to a frame");
418 };
419 assert!(!frame.props.header.has_labels());
420 }
421}