Skip to main content

qframe/widgets/
empty_state.rs

1//! Empty states: what an area says when it has nothing to show.
2
3use super::Button;
4use crate::geometry::{Rect, Size, clamp_u16};
5use crate::style::CellStyle;
6use crate::text;
7use crate::widget::{MeasureCx, Node, PaintCx, Widget};
8
9/// Widest a message line gets, so the explanation reads as a short paragraph on wide screens.
10const MAX_WIDTH: u16 = 52;
11
12/// A centred block explaining why an area is empty and what to do about it.
13///
14/// Reads, from top to bottom: an optional muted icon, a title, an optional explanation that wraps
15/// to at most 52 cells, and an optional action button. When the area is short, the icon goes
16/// first, then the space above the action, then explanation lines; the title and the action stay.
17///
18/// Style keys: `empty-state-icon` (`fg`), `empty-state-title` (`fg`, `bold`),
19/// `empty-state-message` (`fg`).
20pub struct EmptyState<Msg> {
21    title: String,
22    icon: Option<String>,
23    message: Option<String>,
24    action: Vec<Node<Msg>>,
25}
26
27impl<Msg: Clone + 'static> EmptyState<Msg> {
28    /// An empty state reading `title`, e.g. "No containers yet".
29    #[must_use]
30    pub fn new(title: impl Into<String>) -> Self {
31        Self { title: title.into(), icon: None, message: None, action: Vec::new() }
32    }
33
34    /// Icon key drawn above the title in a muted colour.
35    #[must_use]
36    pub fn icon(mut self, key: impl Into<String>) -> Self {
37        self.icon = Some(key.into());
38        self
39    }
40
41    /// One or two sentences under the title: why it is empty, or what will appear here.
42    #[must_use]
43    pub fn message(mut self, message: impl Into<String>) -> Self {
44        self.message = Some(message.into());
45        self
46    }
47
48    /// The way out, e.g. `Button::new("Create container").variant("primary").on_press(..)`.
49    #[must_use]
50    pub fn action(mut self, button: Button<Msg>) -> Self {
51        self.action = vec![Node::new(button, 0)];
52        self
53    }
54}
55
56/// Which parts fit, and the message lines shown.
57struct Plan {
58    icon: bool,
59    lines: Vec<String>,
60    action_gap: bool,
61    action: bool,
62}
63
64impl Plan {
65    fn height(&self) -> u16 {
66        let lines = clamp_u16(i32::try_from(self.lines.len()).unwrap_or(i32::MAX));
67        u16::from(self.icon) * 2 + 1 + lines + u16::from(self.action_gap) + u16::from(self.action)
68    }
69}
70
71impl<Msg: Clone + 'static> EmptyState<Msg> {
72    fn plan(&self, width: u16, height: u16) -> Plan {
73        let text_width = width.min(MAX_WIDTH);
74        let lines = self.message.as_deref().map(|message| text::wrap(message, text_width)).unwrap_or_default();
75        let action = !self.action.is_empty();
76        let mut plan = Plan { icon: self.icon.is_some(), lines, action_gap: action, action };
77        if plan.height() > height {
78            plan.icon = false;
79        }
80        if plan.height() > height {
81            plan.action_gap = false;
82        }
83        while plan.height() > height && !plan.lines.is_empty() {
84            plan.lines.pop();
85            if let Some(last) = plan.lines.last_mut() {
86                // The explanation was cut: say so on its last visible line.
87                let budget = text_width.saturating_sub(1);
88                let cut = text::truncate(last, budget).into_owned();
89                *last = if cut.ends_with(text::ELLIPSIS) { cut } else { format!("{cut}{}", text::ELLIPSIS) };
90            }
91        }
92        if plan.height() > height {
93            plan.action = false;
94        }
95        plan
96    }
97}
98
99impl<Msg: Clone + 'static> Widget<Msg> for EmptyState<Msg> {
100    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
101        let plan = self.plan(available.width, u16::MAX);
102        Size::new(available.width, plan.height()).min(available)
103    }
104
105    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
106        if area.is_empty() {
107            return;
108        }
109        let plan = self.plan(area.width, area.height);
110        let top = area.y + i32::from((area.height.saturating_sub(plan.height())) / 2);
111        let mut y = top;
112        let centred = |cx: &mut PaintCx<'_>, y: i32, line: &str, style: CellStyle| {
113            let shown = text::truncate(line, area.width).into_owned();
114            let x = area.x + i32::from((area.width - text::width(&shown)) / 2);
115            cx.text(x, y, &shown, style, area.width);
116        };
117
118        if plan.icon
119            && let Some(icon) = &self.icon
120        {
121            let glyph = cx.env().icons().glyph(icon).into_owned();
122            let style = text_style(cx, "empty-state-icon", "muted");
123            centred(cx, y, &glyph, style);
124            y += 2;
125        }
126        let title_style = text_style(cx, "empty-state-title", "text");
127        centred(cx, y, &self.title, title_style);
128        y += 1;
129        let message_style = text_style(cx, "empty-state-message", "dim");
130        for line in &plan.lines {
131            centred(cx, y, line, message_style);
132            y += 1;
133        }
134        if plan.action_gap {
135            y += 1;
136        }
137        if plan.action
138            && let Some(button) = self.action.first()
139        {
140            let size = cx.measure_child(button, Size::new(area.width, 1));
141            let x = area.x + i32::from((area.width - size.width) / 2);
142            cx.paint_child(button, Rect::new(x, y, size.width, 1));
143        }
144    }
145
146    fn children(&self) -> &[Node<Msg>] {
147        &self.action
148    }
149
150    fn children_mut(&mut self) -> &mut [Node<Msg>] {
151        &mut self.action
152    }
153}
154
155/// The text style of `widget`, without a background, falling back to colour token `fallback`.
156fn text_style(cx: &mut PaintCx<'_>, widget: &str, fallback: &str) -> CellStyle {
157    let mut style = cx.style(widget, None, &[]).text();
158    style.bg = None;
159    style.fg = style.fg.or_else(|| Some(cx.color(fallback)));
160    style
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::runtime::{App, Command, Harness};
167    use crate::widget::View;
168
169    #[derive(Default)]
170    struct Demo {
171        created: u32,
172    }
173
174    impl App for Demo {
175        type Msg = ();
176        fn update(&mut self, _: ()) -> Command<()> {
177            self.created += 1;
178            Command::none()
179        }
180        fn view(&self, ui: &mut View<'_, ()>) {
181            ui.add(
182                EmptyState::new("No containers")
183                    .icon("dot-outline")
184                    .message("Containers you run appear here.")
185                    .action(Button::new("Run").on_press(())),
186            )
187            .fill()
188            .id("empty");
189        }
190    }
191
192    #[test]
193    fn centres_icon_title_message_and_action() {
194        let h = Harness::new(Demo::default(), 36, 9);
195        assert_eq!(
196            h.screen(),
197            "\n                 ○\n\n           No containers\n  Containers you run appear here.\n\n                Run\n\n\n"
198        );
199        let theme = h.env().theme();
200        assert_eq!(h.fg(17, 1), theme.color("muted"));
201        assert!(h.is_bold(11, 3));
202        assert_eq!(h.fg(2, 4), theme.color("dim"));
203    }
204
205    #[test]
206    fn action_is_focusable_and_clickable() {
207        let mut h = Harness::new(Demo::default(), 36, 9);
208        h.press("tab").press("enter");
209        assert_eq!(h.app().created, 1);
210        h.click_text("Run");
211        assert_eq!(h.app().created, 2);
212    }
213
214    #[test]
215    fn short_and_narrow_areas_keep_title_and_action() {
216        let h = Harness::new(Demo::default(), 20, 4);
217        assert_eq!(h.screen(), "   No containers\n Containers you run\n    appear here.\n        Run\n");
218        let tiny = Harness::new(Demo::default(), 20, 3);
219        assert_eq!(tiny.screen(), "   No containers\nContainers you run…\n        Run\n");
220    }
221}