| 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 |