1use super::Button;
4use crate::geometry::{Rect, Size, clamp_u16};
5use crate::style::CellStyle;
6use crate::text;
7use crate::widget::{MeasureCx, Node, PaintCx, Widget};
8
9const MAX_WIDTH: u16 = 52;
11
12pub 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 #[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 #[must_use]
36 pub fn icon(mut self, key: impl Into<String>) -> Self {
37 self.icon = Some(key.into());
38 self
39 }
40
41 #[must_use]
43 pub fn message(mut self, message: impl Into<String>) -> Self {
44 self.message = Some(message.into());
45 self
46 }
47
48 #[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
56struct 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 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
155fn 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}