Skip to main content

tui_lipan/widgets/toast/
mod.rs

1//! Toast widget.
2
3use 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/// A transient notification message.
35#[derive(Clone)]
36pub struct Toast {
37    /// Message content.
38    pub message: Arc<str>,
39    /// Duration in seconds.
40    pub duration: f64,
41    /// Dismiss when clicked on.
42    pub dismiss_on_click: bool,
43    /// Show a copy affordance that copies the whole toast message when clicked.
44    pub copyable: bool,
45    /// Optional visual affordance for copyable toasts.
46    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/// Visual affordance used for copyable toasts.
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68pub enum ToastCopyAffordance {
69    /// Do not render an explicit copy control. The toast can still be copied by right-clicking it.
70    None,
71    /// Render the copy glyph in the top border when the toast has a border.
72    BorderGlyph,
73}
74
75impl Toast {
76    /// Create a new toast with the given message.
77    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    /// Set duration in seconds.
105    pub fn duration(mut self, secs: f64) -> Self {
106        self.duration = secs;
107        self
108    }
109
110    /// Set title.
111    pub fn title(mut self, title: Option<impl Into<Arc<str>>>) -> Self {
112        self.title = title.map(Into::into);
113        self
114    }
115
116    /// Set an optional prefix rendered before the title.
117    pub fn title_prefix(mut self, prefix: impl Into<RichText>) -> Self {
118        self.title_prefix = Some(prefix.into());
119        self
120    }
121
122    /// Set an optional suffix rendered after the title.
123    pub fn title_suffix(mut self, suffix: impl Into<RichText>) -> Self {
124        self.title_suffix = Some(suffix.into());
125        self
126    }
127
128    /// Set the title alignment in the top border.
129    pub fn title_alignment(mut self, align: Align) -> Self {
130        self.title_alignment = align;
131        self
132    }
133
134    /// Set title style.
135    pub fn title_style(mut self, style: Style) -> Self {
136        self.title_style = style;
137        self
138    }
139
140    /// Set message style.
141    pub fn message_style(mut self, style: Style) -> Self {
142        self.message_style = style;
143        self
144    }
145
146    /// Set frame style.
147    pub fn frame_style(mut self, style: Style) -> Self {
148        self.frame_style = style;
149        self
150    }
151
152    /// Draw a border.
153    pub fn border(mut self, border: bool) -> Self {
154        self.border = border;
155        self
156    }
157
158    /// Set border style.
159    pub fn border_style(mut self, border_style: BorderStyle) -> Self {
160        self.border_style = border_style;
161        self
162    }
163
164    /// Set padding for the header (title). Top/bottom are ignored.
165    pub fn header_padding(mut self, padding: impl Into<Padding>) -> Self {
166        self.header_padding = padding.into();
167        self
168    }
169
170    /// Set padding.
171    pub fn padding(mut self, padding: impl Into<Padding>) -> Self {
172        self.padding = padding.into();
173        self
174    }
175
176    /// Add an edge decoration.
177    pub fn decoration(mut self, decoration: EdgeDecoration) -> Self {
178        self.decorations.push(decoration);
179        self
180    }
181
182    /// Replace all edge decorations.
183    pub fn decorations(mut self, decorations: Vec<EdgeDecoration>) -> Self {
184        self.decorations = decorations;
185        self
186    }
187
188    /// Set width.
189    pub fn width(mut self, width: Length) -> Self {
190        self.width = width;
191        self
192    }
193
194    /// Set height.
195    pub fn height(mut self, height: Length) -> Self {
196        self.height = height;
197        self
198    }
199
200    /// Set minimum width.
201    pub fn min_width(mut self, width: Length) -> Self {
202        self.min_width = Some(width);
203        self
204    }
205
206    /// Set maximum width (wraps content when exceeded).
207    pub fn max_width(mut self, width: Length) -> Self {
208        self.max_width = Some(width);
209        self
210    }
211
212    /// Set whether clicking on the toast dismisses it.
213    pub fn dismiss_on_click(mut self, dismiss: bool) -> Self {
214        self.dismiss_on_click = dismiss;
215        self
216    }
217
218    /// Set whether the toast can copy its message.
219    ///
220    /// Copyable toasts copy their message when right-clicked. When the toast has a border and
221    /// [`ToastCopyAffordance::BorderGlyph`] is enabled, they also show a copy glyph that can be
222    /// clicked with the left mouse button.
223    pub fn copyable(mut self, copyable: bool) -> Self {
224        self.copyable = copyable;
225        self
226    }
227
228    /// Set the optional visual copy affordance for copyable toasts.
229    pub fn copy_affordance(mut self, affordance: ToastCopyAffordance) -> Self {
230        self.copy_affordance = affordance;
231        self
232    }
233
234    /// Set whether message text should wrap.
235    pub fn wrap(mut self, wrap: bool) -> Self {
236        self.wrap = wrap;
237        self
238    }
239
240    /// Convert to element.
241    pub fn into_element(self) -> Element {
242        let min_width = self.min_width;
243        let max_width = self.max_width;
244
245        // Give the message the frame's background so it reads as one surface - but never a
246        // translucent one. The frame has already composited that paint against what it covers;
247        // handing the same paint to the text would composite it a second time, over the result,
248        // leaving a darker patch behind the words. Left unset, the text simply keeps the surface
249        // the frame painted.
250        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}