返回 CodeWhale
icons.rs
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
166 lines RUST