1use 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
14const MAX_WIDTH: u16 = 52;
16
17const ACTION_GAP: u16 = 2;
19
20pub 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 #[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 #[must_use]
55 pub fn icon(mut self, key: impl Into<String>) -> Self {
56 self.icon = Some(key.into());
57 self
58 }
59
60 #[must_use]
62 pub fn message(mut self, message: impl Into<String>) -> Self {
63 self.message = Some(message.into());
64 self
65 }
66
67 #[must_use]
72 pub fn tone(mut self, tone: ToastKind) -> Self {
73 self.tone = Some(tone);
74 self
75 }
76
77 #[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 fn icon_key(&self) -> Option<&str> {
92 self.tone.map(ToastKind::icon).or(self.icon.as_deref())
93 }
94}
95
96struct Plan {
98 icon: bool,
99 lines: Vec<String>,
100 action_gap: bool,
101 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
112struct ActionRow {
117 rows: u16,
119 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 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 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 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 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
277trait 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
295fn 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 #[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 #[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 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 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}