返回 CodeWhale
empty.rs
1 //! An empty state: what is true, and the one thing to do about it.
2 //!
3 //! ```text
4 //! ⣠⣶⣶⣄
5 //! ⠿⣿⣿⣿⡿ (the whale mark, one ink, only where it fits)
6 //!
7 //! No workflow runs yet
8 //! Runs appear here once one starts.
9 //! Start one with /workflow
10 //! ```
11 //!
12 //! It has one sentence (the title), an optional body and at most one action:
13 //! a list that is empty should never need more. The mark is identity, not
14 //! delight: the kit's whale in `Primary`, drawn only when the area has the
15 //! rows for it (the 16-column compact whale, a few rows above the text).
16 //! Braille has no honest ASCII form, so ASCII-safe terminals and areas too
17 //! small for the whale get a one-cell glyph before the title instead (`○`,
18 //! `o` in ASCII). The words are all parameters; the kit owns none of them.
19 //!
20 //! Replaces `EmptyState` in the engine's `views/mod.rs` and the inline empty
21 //! messages in `workflows_manager.rs`, `widgets/mod.rs` and `underwater.rs`
22 //! (`Hmbown/CodeWhale` `58b1dd3dd`).
23
24 use std::borrow::Cow;
25
26 use ratatui::{
27 buffer::Buffer,
28 layout::Rect,
29 style::Modifier,
30 text::{Line, Span},
31 widgets::Widget,
32 };
33
34 use crate::{
35 Paint, Role, Theme, WhaleState, glyphs, text,
36 whale::{Grid, rasterize, scene},
37 };
38
39 /// What stands above the title.
40 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
41 pub enum EmptyMark {
42 /// The whale where it fits (16x8 cells, or 8x4 in less room), else a
43 /// glyph before the title. The default.
44 #[default]
45 Whale,
46 /// The glyph before the title, never the whale.
47 Glyph,
48 /// Words only.
49 None,
50 }
51
52 /// The whale's viewport in cells: the kit's compact size. Smaller viewports
53 /// pick lower-detail scenes that stop reading as a whale, so below this
54 /// there is a glyph instead.
55 const WHALE: (u16, u16) = (16, 8);
56 /// Body lines shown at most.
57 const BODY_LINES: usize = 3;
58
59 /// An empty area's message. Build it, then [`Paint::paint`] it into the area
60 /// the content would have filled; it centres itself there.
61 #[derive(Clone, Debug, PartialEq, Eq)]
62 pub struct EmptyState<'a> {
63 title: Cow<'a, str>,
64 body: Option<Cow<'a, str>>,
65 action: Option<Cow<'a, str>>,
66 mark: EmptyMark,
67 }
68
69 impl<'a> EmptyState<'a> {
70 /// `title` says what is true: "No workflow runs yet".
71 #[must_use]
72 pub fn new(title: impl Into<Cow<'a, str>>) -> Self {
73 Self {
74 title: title.into(),
75 body: None,
76 action: None,
77 mark: EmptyMark::default(),
78 }
79 }
80
81 /// A sentence of context, shown in `Muted` and wrapped to the width.
82 #[must_use]
83 pub fn body(mut self, body: impl Into<Cow<'a, str>>) -> Self {
84 self.body = Some(body.into());
85 self
86 }
87
88 /// The one suggested next step ("Start one with /workflow"), in
89 /// `Primary` on its own line.
90 #[must_use]
91 pub fn action(mut self, action: impl Into<Cow<'a, str>>) -> Self {
92 self.action = Some(action.into());
93 self
94 }
95
96 #[must_use]
97 pub const fn mark(mut self, mark: EmptyMark) -> Self {
98 self.mark = mark;
99 self
100 }
101
102 /// The lines that fit `width` and `height`, with the mark chosen.
103 fn plan(&self, width: u16, height: u16, ascii: bool) -> Plan {
104 let inner = usize::from(width.saturating_sub(4).max(1));
105 let body = self
106 .body
107 .as_deref()
108 .map(|b| wrap(&text::display_safe(b), inner, BODY_LINES, ascii))
109 .unwrap_or_default();
110 let action = self.action.is_some();
111
112 // Rows for text. Squeeze the body, then the action, never the title.
113 let rows = |body: usize, action: bool| 1 + body + usize::from(action);
114 let (keep_body, keep_action) = if usize::from(height) >= rows(body.len(), action) {
115 (body.len(), action)
116 } else if usize::from(height) >= rows(0, action) {
117 (0, action)
118 } else {
119 (0, false)
120 };
121 let text_rows = rows(keep_body, keep_action);
122 let spare = usize::from(height).saturating_sub(text_rows);
123
124 let art = (self.mark == EmptyMark::Whale && !ascii && width >= WHALE.0 + 8)
125 .then(Art::whale)
126 .flatten()
127 // One row of air between the whale and the title.
128 .filter(|art| spare > usize::from(art.rows));
129 let glyph = art.is_none() && self.mark != EmptyMark::None;
130 Plan {
131 body: body.into_iter().take(keep_body).collect(),
132 action: keep_action,
133 art,
134 glyph,
135 }
136 }
137 }
138
139 /// The whale's Braille cells with the empty rows at the top and bottom cut.
140 struct Art {
141 grid: Grid,
142 first: u16,
143 rows: u16,
144 }
145
146 impl Art {
147 fn whale() -> Option<Self> {
148 let grid = rasterize(
149 scene(WhaleState::Rest, u32::from(WHALE.0) * 2)?,
150 WHALE.0,
151 WHALE.1,
152 0.5,
153 );
154 let blank = |r: u16| (0..grid.cols).all(|c| grid.cell(c, r) == 0);
155 let first = (0..grid.rows).find(|&r| !blank(r))?;
156 let last = (0..grid.rows).rev().find(|&r| !blank(r))?;
157 Some(Self {
158 first,
159 rows: last - first + 1,
160 grid,
161 })
162 }
163 }
164
165 struct Plan {
166 body: Vec<String>,
167 action: bool,
168 art: Option<Art>,
169 glyph: bool,
170 }
171
172 impl Plan {
173 fn rows(&self) -> u16 {
174 let art = self.art.as_ref().map_or(0, |a| a.rows + 1);
175 art + 1 + u16::try_from(self.body.len()).unwrap_or(0) + u16::from(self.action)
176 }
177 }
178
179 /// Greedy word wrap to `width` cells, at most `max` lines, the last cut with
180 /// an ellipsis. A word wider than the line is cut inside it.
181 fn wrap(body: &str, width: usize, max: usize, ascii: bool) -> Vec<String> {
182 let mut lines: Vec<String> = Vec::new();
183 let mut line = String::new();
184 for word in body.split_whitespace() {
185 let fits = line.is_empty() || text::width(&line) + 1 + text::width(word) <= width;
186 if !fits {
187 lines.push(std::mem::take(&mut line));
188 }
189 if !line.is_empty() {
190 line.push(' ');
191 }
192 line.push_str(word);
193 }
194 if !line.is_empty() {
195 lines.push(line);
196 }
197 let clipped = lines.len() > max;
198 lines.truncate(max);
199 let last = lines.len().saturating_sub(1);
200 for (i, l) in lines.iter_mut().enumerate() {
201 if i == last && clipped {
202 l.push(' ');
203 l.push_str(glyphs::pick(glyphs::ELLIPSIS, ascii));
204 }
205 *l = text::truncate(l, width, ascii).into_owned();
206 }
207 lines
208 }
209
210 impl Paint for EmptyState<'_> {
211 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
212 let area = area.intersection(buf.area);
213 if area.is_empty() {
214 return;
215 }
216 let ascii = theme.ascii();
217 let plan = self.plan(area.width, area.height, ascii);
218 let top = area.y + area.height.saturating_sub(plan.rows()) / 2;
219 let mut y = top;
220 let line = |y: u16| Rect {
221 y,
222 height: 1,
223 ..area
224 };
225
226 if let Some(art) = &plan.art {
227 let x0 = area.x + (area.width - art.grid.cols) / 2;
228 let ink = theme.fg(Role::Primary);
229 for r in 0..art.rows {
230 for c in 0..art.grid.cols {
231 let ch = art.grid.char_at(c, art.first + r);
232 if ch != ' ' {
233 let mut tmp = [0u8; 4];
234 buf[(x0 + c, y + r)]
235 .set_symbol(ch.encode_utf8(&mut tmp))
236 .set_style(ink);
237 }
238 }
239 }
240 y += art.rows + 1;
241 }
242
243 let title = text::display_safe(&self.title);
244 let mut spans = Vec::new();
245 let mut room = usize::from(area.width);
246 if plan.glyph {
247 let mark = glyphs::pick(glyphs::AVAILABLE, ascii);
248 spans.push(Span::styled(format!("{mark} "), theme.fg(Role::Muted)));
249 room = room.saturating_sub(text::width(mark) + 1);
250 }
251 spans.push(Span::styled(
252 text::truncate(&title, room, ascii).into_owned(),
253 theme.fg(Role::Foreground).add_modifier(Modifier::BOLD),
254 ));
255 Line::from(spans).centered().render(line(y), buf);
256 y += 1;
257
258 for body in &plan.body {
259 Line::from(Span::styled(body.clone(), theme.fg(Role::Muted)))
260 .centered()
261 .render(line(y), buf);
262 y += 1;
263 }
264 if plan.action
265 && let Some(action) = &self.action
266 {
267 let action = text::display_safe(action);
268 let shown = text::truncate_words(&action, usize::from(area.width), ascii);
269 Line::from(Span::styled(shown.into_owned(), theme.fg(Role::Primary)))
270 .centered()
271 .render(line(y), buf);
272 }
273 }
274
275 fn height(&self, width: u16, theme: &Theme) -> u16 {
276 // As many rows as it would take with room to spare.
277 self.plan(width, u16::MAX, theme.ascii()).rows()
278 }
279 }
280
280 lines RUST