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 super::cells;
5use super::toast::ToastKind;
6use crate::color::Rgb;
7use crate::event::Event;
8use crate::geometry::{Rect, Size, clamp_u16};
9use crate::keymap::Key;
10use crate::style::CellStyle;
11use crate::text;
12use crate::widget::{EventCx, MeasureCx, Node, PaintCx, Widget};
13
14/// Widest a message line gets, so the explanation reads as a short paragraph on wide screens.
15const MAX_WIDTH: u16 = 52;
16
17/// Cells between two buttons standing side by side, the gap a dialog keeps its action row at.
18const ACTION_GAP: u16 = 2;
19
20/// A centred block explaining why an area is empty and what to do about it.
21///
22/// Reads, from top to bottom: an optional muted icon, a title, an optional explanation that wraps
23/// to at most 52 cells, and the action buttons. When the area is short, the icon goes first, then
24/// the space above the buttons, then explanation lines; the title and the buttons stay.
25///
26/// [`action`](Self::action) may be called more than once: every call adds a button, and the first
27/// one added is the primary choice in reading and focus order. They stand side by side, centred,
28/// `ACTION_GAP` cells apart as in a dialog; where they do not fit the width they stand one under
29/// another, centred, with a blank row between so their surfaces never merge. Tab and the arrow keys
30/// visit them in the order they were added.
31///
32/// [`tone`](Self::tone) marks the block as a status: the icon and the title take the `success`,
33/// `warning`, `danger` or `info` colour a [`Toast`](super::Toast) marks its kind with, and the
34/// icon is the sign that goes with the colour, so an error is never a bare colour.
35///
36/// Style keys: `empty-state-icon` (`fg`), `empty-state-title` (`fg`, `bold`),
37/// `empty-state-message` (`fg`).
38pub struct EmptyState<Msg> {
39    title: String,
40    icon: Option<String>,
41    message: Option<String>,
42    tone: Option<ToastKind>,
43    actions: Vec<Node<Msg>>,
44}
45
46impl<Msg: Clone + 'static> EmptyState<Msg> {
47    /// An empty state reading `title`, e.g. "No containers yet".
48    #[must_use]
49    pub fn new(title: impl Into<String>) -> Self {
50        Self { title: title.into(), icon: None, message: None, tone: None, actions: Vec::new() }
51    }
52
53    /// Icon key drawn above the title in a muted colour when no status tone supplies its sign.
54    #[must_use]
55    pub fn icon(mut self, key: impl Into<String>) -> Self {
56        self.icon = Some(key.into());
57        self
58    }
59
60    /// One or two sentences under the title: why it is empty, or what will appear here.
61    #[must_use]
62    pub fn message(mut self, message: impl Into<String>) -> Self {
63        self.message = Some(message.into());
64        self
65    }
66
67    /// The status the block reports: the icon and the title take the `success`, `warning`,
68    /// `danger` or `info` colour, and the icon is the sign that goes with it. Without a tone both
69    /// keep the colours the theme gives them. A tone supplies its own sign in place of a custom
70    /// [`icon`](Self::icon) key.
71    #[must_use]
72    pub fn tone(mut self, tone: ToastKind) -> Self {
73        self.tone = Some(tone);
74        self
75    }
76
77    /// The way out, e.g. `Button::new("Create container").variant("primary").on_press(..)`.
78    ///
79    /// Call it once for each of several equal choices, such as "Install here" beside "Show the
80    /// command": they keep the order they are added in, so the first one is the primary, the one
81    /// to mark `primary`, and the one Tab and the arrow keys reach first.
82    #[must_use]
83    pub fn action(mut self, button: Button<Msg>) -> Self {
84        let index = self.actions.len();
85        self.actions.push(Node::new(button, index));
86        self
87    }
88
89    /// The sign above the title: a tone brings its own sign, while a state without one keeps the
90    /// key given to [`icon`](Self::icon).
91    fn icon_key(&self) -> Option<&str> {
92        self.tone.map(ToastKind::icon).or(self.icon.as_deref())
93    }
94}
95
96/// Which parts fit, and the message lines shown.
97struct Plan {
98    icon: bool,
99    lines: Vec<String>,
100    action_gap: bool,
101    /// Rows the buttons take, zero when there is nowhere to put them.
102    action_rows: u16,
103}
104
105impl Plan {
106    fn height(&self) -> u16 {
107        let lines = clamp_u16(i32::try_from(self.lines.len()).unwrap_or(i32::MAX));
108        cells::sum([u16::from(self.icon) * 2, 1, lines, u16::from(self.action_gap), self.action_rows])
109    }
110}
111
112/// Where the action buttons go under the message, in the order they were added.
113///
114/// They share one centred row while they fit `width`; where they do not they stand one under
115/// another, each centred on its own width, a blank row apart so their surfaces never merge.
116struct ActionRow {
117    /// Rows the buttons take, the blank ones included.
118    rows: u16,
119    /// One rect per button: `x` counts cells from the left edge of the area, `y` rows down from
120    /// the first button row.
121    rects: Vec<Rect>,
122}
123
124impl ActionRow {
125    fn place(sizes: &[Size], width: u16) -> Self {
126        let count = u16::try_from(sizes.len()).unwrap_or(u16::MAX);
127        if count == 0 {
128            return Self { rows: 0, rects: Vec::new() };
129        }
130        let one_row =
131            cells::sum(sizes.iter().map(|size| size.width)).saturating_add(ACTION_GAP.saturating_mul(count - 1));
132        if one_row <= width || count == 1 {
133            let row_width = one_row.min(width);
134            let mut from_left = (width - row_width) / 2;
135            let rects = sizes
136                .iter()
137                .map(|size| {
138                    let button_width = size.width.min(width);
139                    let rect = Rect::new(i32::from(from_left), 0, button_width, 1);
140                    from_left = from_left.saturating_add(button_width).saturating_add(ACTION_GAP);
141                    rect
142                })
143                .collect();
144            return Self { rows: 1, rects };
145        }
146        let rects = sizes
147            .iter()
148            .enumerate()
149            .map(|(index, size)| {
150                let button_width = size.width.min(width);
151                let left = (width - button_width) / 2;
152                let down = i32::try_from(index).unwrap_or(i32::MAX).saturating_mul(2);
153                Rect::new(i32::from(left), down, button_width, 1)
154            })
155            .collect();
156        Self { rows: count.saturating_mul(2).saturating_sub(1), rects }
157    }
158}
159
160impl<Msg: Clone + 'static> EmptyState<Msg> {
161    /// Which parts of the block fit in `width` by `height`, given where the buttons were placed.
162    fn plan(&self, width: u16, height: u16, actions: &ActionRow) -> Plan {
163        let text_width = width.min(MAX_WIDTH);
164        let lines = self.message.as_deref().map(|message| text::wrap(message, text_width)).unwrap_or_default();
165        let mut plan =
166            Plan { icon: self.icon_key().is_some(), lines, action_gap: actions.rows > 0, action_rows: actions.rows };
167        if plan.height() > height {
168            plan.icon = false;
169        }
170        if plan.height() > height {
171            plan.action_gap = false;
172        }
173        while plan.height() > height && !plan.lines.is_empty() {
174            plan.lines.pop();
175            if let Some(last) = plan.lines.last_mut() {
176                // The explanation was cut: say so on its last visible line.
177                let budget = text_width.saturating_sub(1);
178                let cut = text::truncate(last, budget).into_owned();
179                *last = if cut.ends_with(text::ELLIPSIS) { cut } else { format!("{cut}{}", text::ELLIPSIS) };
180            }
181        }
182        if plan.height() > height {
183            plan.action_rows = 0;
184        }
185        plan
186    }
187}
188
189impl<Msg: Clone + 'static> Widget<Msg> for EmptyState<Msg> {
190    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
191        let sizes = self.button_sizes(cx, available.width);
192        let plan = self.plan(available.width, u16::MAX, &ActionRow::place(&sizes, available.width));
193        Size::new(available.width, plan.height()).min(available)
194    }
195
196    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
197        if area.is_empty() {
198            return;
199        }
200        let sizes = self.button_sizes(cx, area.width);
201        let row = ActionRow::place(&sizes, area.width);
202        let plan = self.plan(area.width, area.height, &row);
203        let top = area.y + i32::from((area.height.saturating_sub(plan.height())) / 2);
204        let mut y = top;
205        let status = self.tone.map(|tone| cx.color(tone.name()));
206        let centred = |cx: &mut PaintCx<'_>, y: i32, line: &str, style: CellStyle| {
207            let shown = text::truncate(line, area.width).into_owned();
208            let x = area.x + i32::from((area.width - text::width(&shown)) / 2);
209            cx.text(x, y, &shown, style, area.width);
210        };
211
212        if plan.icon
213            && let Some(icon) = self.icon_key()
214        {
215            let glyph = cx.env().icons().glyph(icon).into_owned();
216            let style = text_style(cx, "empty-state-icon", "muted", status);
217            centred(cx, y, &glyph, style);
218            y += 2;
219        }
220        let title_style = text_style(cx, "empty-state-title", "text", status);
221        centred(cx, y, &self.title, title_style);
222        y += 1;
223        let message_style = text_style(cx, "empty-state-message", "dim", None);
224        for line in &plan.lines {
225            centred(cx, y, line, message_style);
226            y += 1;
227        }
228        if plan.action_gap {
229            y += 1;
230        }
231        if plan.action_rows == 0 {
232            return;
233        }
234        // Painted in the order they were added, so Tab reads them the way the eye does whether
235        // they share a row or stand one under another.
236        for (button, rect) in self.actions.iter().zip(&row.rects) {
237            cx.paint_child(button, Rect::new(area.x + rect.x, y + rect.y, rect.width, rect.height));
238        }
239    }
240
241    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
242        let Event::Key(key) = event else { return false };
243        let step = if key.is_plain(Key::Left) || key.is_plain(Key::Up) {
244            -1
245        } else if key.is_plain(Key::Right) || key.is_plain(Key::Down) {
246            1
247        } else {
248            return false;
249        };
250        if self.actions.len() < 2 || cx.focused_area().is_none() {
251            return false;
252        }
253        cx.focus_step(step);
254        true
255    }
256
257    fn children(&self) -> &[Node<Msg>] {
258        &self.actions
259    }
260
261    fn children_mut(&mut self) -> &mut [Node<Msg>] {
262        &mut self.actions
263    }
264}
265
266impl<Msg: Clone + 'static> EmptyState<Msg> {
267    /// The size each button asks for in `width` cells: capped to the width it is given, so a
268    /// label too long for the area is cut instead of pushing the row past the edge.
269    fn button_sizes<M>(&self, cx: &mut M, width: u16) -> Vec<Size>
270    where
271        M: ButtonSizer<Msg>,
272    {
273        self.actions.iter().map(|action| cx.size_of(action, Size::new(width, 1))).collect()
274    }
275}
276
277/// The one thing [`EmptyState::button_sizes`] needs from a painting or measuring context, so both
278/// read the buttons the same way.
279trait ButtonSizer<Msg> {
280    fn size_of(&mut self, button: &Node<Msg>, available: Size) -> Size;
281}
282
283impl<Msg: 'static> ButtonSizer<Msg> for MeasureCx<'_> {
284    fn size_of(&mut self, button: &Node<Msg>, available: Size) -> Size {
285        self.measure_child(button, available)
286    }
287}
288
289impl<Msg: 'static> ButtonSizer<Msg> for PaintCx<'_> {
290    fn size_of(&mut self, button: &Node<Msg>, available: Size) -> Size {
291        self.measure_child(button, available)
292    }
293}
294
295/// The text style of `widget`, without a background, falling back to colour token `fallback`. A
296/// tone takes the cells over, so the icon and the title read as one sign.
297fn text_style(cx: &mut PaintCx<'_>, widget: &str, fallback: &str, tone: Option<Rgb>) -> CellStyle {
298    let mut style = cx.style(widget, None, &[]).text();
299    style.bg = None;
300    style.fg = tone.or(style.fg).or_else(|| Some(cx.color(fallback)));
301    style
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use crate::runtime::{App, Command, Harness};
308    use crate::widget::View;
309
310    #[derive(Default)]
311    struct Demo {
312        created: u32,
313    }
314
315    impl App for Demo {
316        type Msg = ();
317        fn update(&mut self, _: ()) -> Command<()> {
318            self.created += 1;
319            Command::none()
320        }
321        fn view(&self, ui: &mut View<'_, ()>) {
322            ui.add(
323                EmptyState::new("No containers")
324                    .icon("dot-outline")
325                    .message("Containers you run appear here.")
326                    .action(Button::new("Run").on_press(())),
327            )
328            .fill()
329            .id("empty");
330        }
331    }
332
333    /// Two equal ways out of a missing program, and how many of them were taken.
334    #[derive(Default)]
335    struct Missing {
336        installed: u32,
337        shown: u32,
338    }
339
340    #[derive(Clone, Copy)]
341    enum MissingMsg {
342        Install,
343        Show,
344    }
345
346    impl App for Missing {
347        type Msg = MissingMsg;
348        fn update(&mut self, message: MissingMsg) -> Command<MissingMsg> {
349            match message {
350                MissingMsg::Install => self.installed += 1,
351                MissingMsg::Show => self.shown += 1,
352            }
353            Command::none()
354        }
355        fn view(&self, ui: &mut View<'_, MissingMsg>) {
356            ui.add(
357                EmptyState::new("ripgrep was not found")
358                    .message("It comes with the packages of this system.")
359                    .action(Button::new("Install here").variant("primary").on_press(MissingMsg::Install))
360                    .action(Button::new("Show the command").on_press(MissingMsg::Show)),
361            )
362            .fill();
363        }
364    }
365
366    /// The same failed index, with and without its status tone.
367    #[derive(Default)]
368    struct Failed {
369        danger: bool,
370    }
371
372    impl App for Failed {
373        type Msg = ();
374        fn update(&mut self, _: ()) -> Command<()> {
375            Command::none()
376        }
377        fn view(&self, ui: &mut View<'_, ()>) {
378            let mut empty = EmptyState::new("The index could not be read");
379            if self.danger {
380                empty = empty.tone(ToastKind::Danger);
381            }
382            ui.add(empty.message("Run quvyta index --repair to build it again.")).fill();
383        }
384    }
385
386    #[test]
387    fn centres_icon_title_message_and_action() {
388        let h = Harness::new(Demo::default(), 36, 9);
389        assert_eq!(
390            h.screen(),
391            "\n                 ○\n\n           No containers\n  Containers you run appear here.\n\n                Run\n\n\n"
392        );
393        let theme = h.env().theme();
394        assert_eq!(h.fg(17, 1), theme.color("muted"));
395        assert!(h.is_bold(11, 3));
396        assert_eq!(h.fg(2, 4), theme.color("dim"));
397    }
398
399    #[test]
400    fn action_is_focusable_and_clickable() {
401        let mut h = Harness::new(Demo::default(), 36, 9);
402        h.press("tab").press("enter");
403        assert_eq!(h.app().created, 1);
404        h.click_text("Run");
405        assert_eq!(h.app().created, 2);
406    }
407
408    #[test]
409    fn short_and_narrow_areas_keep_title_and_action() {
410        let h = Harness::new(Demo::default(), 20, 4);
411        assert_eq!(h.screen(), "   No containers\n Containers you run\n    appear here.\n        Run\n");
412        let tiny = Harness::new(Demo::default(), 20, 3);
413        assert_eq!(tiny.screen(), "   No containers\nContainers you run…\n        Run\n");
414    }
415
416    /// The cell a label is drawn in, so a click lands on the button that carries it.
417    fn cell(h: &Harness<impl App>, label: &str) -> (i32, i32) {
418        let (x, y) = h.find(label).unwrap_or_else(|| panic!("`{label}` on screen:\n{}", h.screen()));
419        (x, y)
420    }
421
422    #[test]
423    fn two_buttons_share_a_row_and_each_answers_for_itself() {
424        let h = Harness::new(Missing::default(), 46, 9);
425        let (install, shown) = (cell(&h, "Install here"), cell(&h, "Show the command"));
426        assert_eq!(install.1, shown.1, "side by side on one row:\n{}", h.screen());
427        // Two cells of gap between the two surfaces, as a dialog keeps its action row.
428        assert_eq!(shown.0 - install.0, i32::try_from("Install here".len()).unwrap_or(i32::MAX) + 6);
429        let mut h = h;
430        let (x, y) = install;
431        h.click(x, y);
432        assert_eq!((h.app().installed, h.app().shown), (1, 0));
433        let (x, y) = cell(&h, "Show the command");
434        h.click(x, y);
435        assert_eq!((h.app().installed, h.app().shown), (1, 1));
436    }
437
438    #[test]
439    fn tab_reaches_the_buttons_in_the_order_they_were_added() {
440        let mut h = Harness::new(Missing::default(), 46, 9);
441        h.press("tab").press("enter");
442        assert_eq!((h.app().installed, h.app().shown), (1, 0), "the first button is first");
443        h.press("tab").press("enter");
444        assert_eq!((h.app().installed, h.app().shown), (1, 1));
445        h.press("shift+tab").press("enter");
446        assert_eq!((h.app().installed, h.app().shown), (2, 1));
447    }
448
449    #[test]
450    fn arrows_move_between_the_buttons() {
451        let mut row = Harness::new(Missing::default(), 46, 9);
452        row.press("tab").press("right").press("enter");
453        assert_eq!((row.app().installed, row.app().shown), (0, 1), "right reaches the second action");
454
455        let mut column = Harness::new(Missing::default(), 24, 11);
456        column.press("tab").press("down").press("enter");
457        assert_eq!((column.app().installed, column.app().shown), (0, 1), "down reaches the stacked action");
458    }
459
460    #[test]
461    fn a_narrow_area_stacks_the_buttons_and_both_still_answer() {
462        let mut h = Harness::new(Missing::default(), 24, 11);
463        let (install, shown) = (cell(&h, "Install here"), cell(&h, "Show the command"));
464        assert!(shown.1 > install.1, "one under another:\n{}", h.screen());
465        assert_eq!(shown.1 - install.1, 2, "a blank row between them:\n{}", h.screen());
466        h.click(install.0, install.1);
467        assert_eq!((h.app().installed, h.app().shown), (1, 0));
468        h.click(shown.0, shown.1);
469        assert_eq!((h.app().installed, h.app().shown), (1, 1));
470    }
471
472    #[test]
473    fn a_short_area_keeps_the_title_and_all_the_actions() {
474        let mut h = Harness::new(Missing::default(), 24, 5);
475        let install = cell(&h, "Install here");
476        let shown = cell(&h, "Show the command");
477        assert!(install.1 < shown.1, "the actions stay in the short block:\n{}", h.screen());
478        h.click(install.0, install.1);
479        h.click(shown.0, shown.1);
480        assert_eq!((h.app().installed, h.app().shown), (1, 1));
481    }
482
483    #[test]
484    fn a_tone_paints_the_icon_and_the_title_in_the_status_colour() {
485        let danger = Harness::new(Failed { danger: true }, 40, 6);
486        let normal = Harness::new(Failed::default(), 40, 6);
487        let title = "The index could not be read";
488        let (title_x, title_y) = cell(&danger, title);
489        let (x, y) = (u16::try_from(title_x).unwrap_or(0), u16::try_from(title_y).unwrap_or(0));
490        let danger_colour = danger.env().theme().color("danger");
491        assert_eq!(danger.fg(x, y), danger_colour, "the title in the danger colour");
492        let (normal_x, normal_y) = cell(&normal, title);
493        assert_eq!(
494            normal.fg(u16::try_from(normal_x).unwrap_or(0), u16::try_from(normal_y).unwrap_or(0)),
495            normal.env().theme().color("text"),
496            "without a tone the title keeps its normal colour"
497        );
498        let icon_glyph = danger.env().icons().glyph("error").into_owned();
499        let (icon_x, icon_y) = cell(&danger, &icon_glyph);
500        assert_eq!(
501            danger.fg(u16::try_from(icon_x).unwrap_or(0), u16::try_from(icon_y).unwrap_or(0)),
502            danger_colour,
503            "the icon is the sign that goes with it"
504        );
505        assert!(danger.is_bold(x, y), "the title keeps its weight");
506    }
507
508    #[test]
509    fn a_tone_keeps_its_sign_in_ascii_and_sixteen_colours() {
510        let mut h = Harness::new(Failed { danger: true }, 40, 6);
511        h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
512        let glyph = h.env().icons().glyph("error").into_owned();
513        let (x, y) = cell(&h, &glyph);
514        let (x, y) = (u16::try_from(x).unwrap_or(0), u16::try_from(y).unwrap_or(0));
515        assert_ne!(h.buffer()[(x, y)].symbol(), " ", "the ASCII sign is not blank: {}", h.screen());
516        h.set_depth(crate::color::ColorDepth::Ansi16);
517        assert_ne!(h.buffer()[(x, y)].symbol(), " ", "the sign remains in sixteen colours: {}", h.screen());
518    }
519}