| 1 | //! Four small marks, one cell each: sonar, tide, shell and kelp. |
| 2 | //! |
| 3 | //! Drawn in the spirit of Susan Kare's icons: precise, friendly, and each |
| 4 | //! with a word for screen readers and an ASCII form for plain terminals. They |
| 5 | //! are marks, not characters: the whale is the only character, and none of |
| 6 | //! these stands for a product concept on its own. Each sits beside words. |
| 7 | |
| 8 | use std::time::Duration; |
| 9 | |
| 10 | use ratatui::{buffer::Buffer, layout::Rect, text::Span, widgets::Widget}; |
| 11 | |
| 12 | use crate::{MotionMode, Paint, Role, Theme}; |
| 13 | |
| 14 | /// A small mark. |
| 15 | #[derive(Clone, Copy, Debug, PartialEq)] |
| 16 | pub enum Icon { |
| 17 | /// Searching: a ping that widens while the search runs. |
| 18 | Sonar { |
| 19 | elapsed: Duration, |
| 20 | motion: MotionMode, |
| 21 | }, |
| 22 | /// Real progress, drawn only when the total is a fact. |
| 23 | Tide { done: u32, total: u32 }, |
| 24 | /// A receipt: something finished and left a record. |
| 25 | Shell, |
| 26 | /// A quiet divider between items on one line. |
| 27 | Kelp, |
| 28 | } |
| 29 | |
| 30 | /// A ping widening: small ring, dotted ring, ring with its centre. None of |
| 31 | /// these is a state mark (`○` is "ready", `·` a separator). |
| 32 | const SONAR: [&str; 3] = ["∘", "◌", "◎"]; |
| 33 | const TIDE: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]; |
| 34 | /// `.` and `#` are the ASCII marks for "working" and "stopped", so the |
| 35 | /// ASCII tide rises through other marks. |
| 36 | const TIDE_ASCII: [&str; 5] = ["_", ",", "-", "=", "%"]; |
| 37 | |
| 38 | impl Icon { |
| 39 | /// A tide mark for `done` of `total`, or `None` when the total is |
| 40 | /// unknown: no fake progress. |
| 41 | #[must_use] |
| 42 | pub fn tide(done: u32, total: u32) -> Option<Self> { |
| 43 | (total > 0).then_some(Self::Tide { |
| 44 | done: done.min(total), |
| 45 | total, |
| 46 | }) |
| 47 | } |
| 48 | |
| 49 | /// The word a screen reader hears. |
| 50 | #[must_use] |
| 51 | pub fn label(&self) -> String { |
| 52 | match self { |
| 53 | Icon::Sonar { .. } => "searching".into(), |
| 54 | Icon::Tide { done, total } => format!("{done} of {total}"), |
| 55 | Icon::Shell => "receipt".into(), |
| 56 | Icon::Kelp => "divider".into(), |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | #[must_use] |
| 61 | pub fn role(&self) -> Role { |
| 62 | match self { |
| 63 | Icon::Sonar { .. } | Icon::Tide { .. } => Role::Live, |
| 64 | Icon::Shell => Role::Muted, |
| 65 | Icon::Kelp => Role::Border, |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | /// The glyph for this terminal and moment. |
| 70 | #[must_use] |
| 71 | pub fn glyph(&self, theme: &Theme) -> &'static str { |
| 72 | let ascii = theme.ascii(); |
| 73 | match *self { |
| 74 | Icon::Sonar { elapsed, motion } => { |
| 75 | if ascii { |
| 76 | // `o` is "ready" in ASCII; a sound wave is not. |
| 77 | ")" |
| 78 | } else if motion.animates() { |
| 79 | SONAR[((elapsed.as_millis() / 250) % SONAR.len() as u128) as usize] |
| 80 | } else { |
| 81 | SONAR[SONAR.len() - 1] |
| 82 | } |
| 83 | } |
| 84 | Icon::Tide { done, total } => { |
| 85 | let table: &[&str] = if ascii { &TIDE_ASCII } else { &TIDE }; |
| 86 | let last = table.len() - 1; |
| 87 | let idx = (u64::from(done) * last as u64 + u64::from(total) / 2) / u64::from(total); |
| 88 | table[idx as usize] |
| 89 | } |
| 90 | Icon::Shell => { |
| 91 | if ascii { |
| 92 | "=" |
| 93 | } else { |
| 94 | "◒" |
| 95 | } |
| 96 | } |
| 97 | Icon::Kelp => { |
| 98 | if ascii { |
| 99 | "|" |
| 100 | } else { |
| 101 | "┊" |
| 102 | } |
| 103 | } |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | #[must_use] |
| 108 | pub fn span(&self, theme: &Theme) -> Span<'static> { |
| 109 | Span::styled(self.glyph(theme), theme.fg(self.role())) |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | impl Paint for Icon { |
| 114 | fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 115 | self.span(theme).render(area, buf); |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | #[cfg(test)] |
| 120 | mod tests { |
| 121 | use super::*; |
| 122 | use crate::testing::Profile; |
| 123 | |
| 124 | #[test] |
| 125 | fn tide_needs_a_real_total() { |
| 126 | assert_eq!(Icon::tide(3, 0), None); |
| 127 | let theme = Profile::DarkTrue.theme(); |
| 128 | assert_eq!(Icon::tide(0, 5).unwrap().glyph(&theme), "▁"); |
| 129 | assert_eq!(Icon::tide(5, 5).unwrap().glyph(&theme), "█"); |
| 130 | assert_eq!(Icon::tide(9, 5).unwrap().label(), "5 of 5"); |
| 131 | } |
| 132 | |
| 133 | #[test] |
| 134 | fn every_icon_has_a_word_and_an_ascii_form() { |
| 135 | let ascii = Profile::Ascii.theme(); |
| 136 | let mut icons: Vec<Icon> = (0..1000) |
| 137 | .step_by(250) |
| 138 | .map(|ms| Icon::Sonar { |
| 139 | elapsed: Duration::from_millis(ms), |
| 140 | motion: MotionMode::Full, |
| 141 | }) |
| 142 | .collect(); |
| 143 | icons.extend((0..=4).map(|done| Icon::tide(done, 4).unwrap())); |
| 144 | icons.extend([Icon::Shell, Icon::Kelp]); |
| 145 | let state_marks: Vec<&str> = crate::State::ALL |
| 146 | .iter() |
| 147 | .flat_map(|s| [s.glyph(), crate::glyphs::pick(s.glyph(), true)]) |
| 148 | .collect(); |
| 149 | for icon in icons { |
| 150 | for profile in [Profile::DarkTrue, Profile::Ascii] { |
| 151 | let glyph = icon.glyph(&profile.theme()); |
| 152 | assert!( |
| 153 | !state_marks.contains(&glyph), |
| 154 | "{icon:?} borrows the state mark {glyph}" |
| 155 | ); |
| 156 | } |
| 157 | assert!(!icon.label().is_empty()); |
| 158 | assert!(icon.glyph(&ascii).is_ascii(), "{icon:?}"); |
| 159 | assert_eq!( |
| 160 | crate::text::width(icon.glyph(&Profile::DarkTrue.theme())), |
| 161 | 1 |
| 162 | ); |
| 163 | } |
| 164 | } |
| 165 | } |
| 166 |