1#![doc = include_str!("../README.md")]
2
3use std::time::Duration;
4
5use ratatui::{
6 buffer::Buffer,
7 layout::Rect,
8 style::{Modifier, Style},
9 widgets::Widget,
10};
11use unicode_segmentation::UnicodeSegmentation;
12use unicode_width::UnicodeWidthStr;
13
14pub const WIDTH: u16 = 11;
15pub const GLASS_ROWS: u16 = 6;
16const LABEL: &str = "Loading...";
17const DRAIN_FRAMES: u128 = 7;
18const FLIP_FRAMES: u128 = 6;
19const DRAIN_MS: u128 = 3000;
20const FLIP_MS: u128 = 1200;
21const SWEEP_MS: u128 = 1200;
22const SHIMMER_MS: u128 = 1500;
23pub const CYCLE: Duration = Duration::from_millis((DRAIN_MS + FLIP_MS) as u64);
24
25pub const FRAMES: [[&str; 6]; 13] = [
26 [
27 " ╭───╮ ",
28 " │⣿⣿⣿│ ",
29 " ╰╮⣿╭╯ ",
30 " ╭╯ ╰╮ ",
31 " │ │ ",
32 " ╰───╯ ",
33 ],
34 [
35 " ╭───╮ ",
36 " │⣿⣿⣿│ ",
37 " ╰╮ ╭╯ ",
38 " ╭╯⡇╰╮ ",
39 " │ ⣀ │ ",
40 " ╰───╯ ",
41 ],
42 [
43 " ╭───╮ ",
44 " │⣿ ⣿│ ",
45 " ╰╮ ╭╯ ",
46 " ╭╯⡇╰╮ ",
47 " │⣀⣤⣀│ ",
48 " ╰───╯ ",
49 ],
50 [
51 " ╭───╮ ",
52 " │⣀ ⣀│ ",
53 " ╰╮ ╭╯ ",
54 " ╭╯⡇╰╮ ",
55 " │⣤⣤⣤│ ",
56 " ╰───╯ ",
57 ],
58 [
59 " ╭───╮ ",
60 " │ │ ",
61 " ╰╮ ╭╯ ",
62 " ╭╯⡇╰╮ ",
63 " │⣿⣤⣿│ ",
64 " ╰───╯ ",
65 ],
66 [
67 " ╭───╮ ",
68 " │ │ ",
69 " ╰╮ ╭╯ ",
70 " ╭╯⡇╰╮ ",
71 " │⣿⣿⣿│ ",
72 " ╰───╯ ",
73 ],
74 [
75 " ╭───╮ ",
76 " │ │ ",
77 " ╰╮ ╭╯ ",
78 " ╭╯ ╰╮ ",
79 " │⣿⣿⣿│ ",
80 " ╰───╯ ",
81 ],
82 [
83 "╭───╮ ",
84 " │ │ ",
85 " ╰╮ ╭╯ ",
86 " ╭╯ ╰╮ ",
87 " │⣿⣿⣿│ ",
88 " ╰───╯",
89 ],
90 [
91 "╭─ ",
92 "│ ──╮ ",
93 "╰─ ⣠╭── ",
94 " ──╯ ⣾⣿⣿─╮",
95 " ╰──⣿│",
96 " ─╯",
97 ],
98 [
99 " ",
100 "╭───╮ ╭───╮",
101 "│ ⣠⣾⣿⣿⣿│",
102 "╰───╯ ╰───╯",
103 " ",
104 " ",
105 ],
106 [
107 " ─╮",
108 " ╭──⣿│",
109 " ──╮ ⣾⣿⣿─╯",
110 "╭─ ╰── ",
111 "│ ──╯ ",
112 "╰─ ",
113 ],
114 [
115 " ╭───╮",
116 " │⣿⣿⣿│ ",
117 " ╰╮ ╭╯ ",
118 " ╭╯ ╰╮ ",
119 " │ │ ",
120 "╰───╯ ",
121 ],
122 [
123 " ╭───╮ ",
124 " │⣿⣿⣿│ ",
125 " ╰╮⣿╭╯ ",
126 " ╭╯ ╰╮ ",
127 " │ │ ",
128 " ╰───╯ ",
129 ],
130];
131
132pub fn frame_at(elapsed: Duration) -> usize {
133 let e = elapsed.as_millis() % CYCLE.as_millis();
134 let frame = if e < DRAIN_MS {
135 e * DRAIN_FRAMES / DRAIN_MS
136 } else {
137 DRAIN_FRAMES + (e - DRAIN_MS) * FLIP_FRAMES / FLIP_MS
138 };
139 frame as usize
140}
141
142fn band_centre(elapsed: Duration, n: usize) -> Option<i128> {
143 let t = elapsed.as_millis() % SHIMMER_MS;
144 if t >= SWEEP_MS {
145 return None;
146 }
147 let reach = n as u128 + 4;
148 Some((t * reach / SWEEP_MS) as i128 - 2)
149}
150
151pub fn band_at(elapsed: Duration, n: usize) -> Option<usize> {
152 let centre = usize::try_from(band_centre(elapsed, n)?).ok()?;
153 (centre < n).then_some(centre)
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub struct Hourglass<'a> {
158 elapsed: Duration,
159 label: &'a str,
160 hint: Option<&'a str>,
161 glass: Style,
162 accent: Style,
163 muted: Style,
164}
165
166impl<'a> Hourglass<'a> {
167 pub fn new(elapsed: Duration) -> Self {
168 Hourglass {
169 elapsed,
170 label: LABEL,
171 hint: None,
172 glass: Style::new(),
173 accent: Style::new(),
174 muted: Style::new(),
175 }
176 }
177
178 pub fn label(mut self, label: &'a str) -> Self {
179 self.label = label;
180 self
181 }
182
183 pub fn hint(mut self, hint: Option<&'a str>) -> Self {
184 self.hint = hint;
185 self
186 }
187
188 pub fn glass(mut self, style: Style) -> Self {
189 self.glass = style;
190 self
191 }
192
193 pub fn accent(mut self, style: Style) -> Self {
194 self.accent = style;
195 self
196 }
197
198 pub fn muted(mut self, style: Style) -> Self {
199 self.muted = style;
200 self
201 }
202
203 fn shade(&self, index: usize, centre: Option<i128>) -> Style {
204 match centre.map(|centre| (index as i128 - centre).abs()) {
205 Some(0) => self.accent.add_modifier(Modifier::BOLD),
206 Some(1) => self.accent,
207 _ => self.muted,
208 }
209 }
210
211 fn shimmer(&self, buf: &mut Buffer, area: Rect, y: u16) {
212 centred(buf, area, y, self.label, |index, n| {
213 self.shade(index, band_centre(self.elapsed, n))
214 });
215 }
216}
217
218fn fitted(text: &str, room: u16) -> impl Iterator<Item = (&str, u16)> + Clone {
219 text.graphemes(true)
220 .filter(|glyph| !glyph.contains(char::is_control))
221 .map(|glyph| (glyph, u16::try_from(glyph.width()).unwrap_or(u16::MAX)))
222 .filter(|(_, width)| *width > 0)
223 .scan(0u16, move |used, (glyph, width)| {
224 *used = used.checked_add(width).filter(|next| *next <= room)?;
225 Some((glyph, width))
226 })
227}
228
229fn centred(
230 buf: &mut Buffer,
231 area: Rect,
232 y: u16,
233 text: &str,
234 shade: impl Fn(usize, usize) -> Style,
235) {
236 let glyphs = fitted(text, area.width);
237 let n = glyphs.clone().count();
238 let used = glyphs
239 .clone()
240 .fold(0u16, |used, (_, width)| used.saturating_add(width));
241 let mut x = area.x.saturating_add(area.width.saturating_sub(used) / 2);
242 for (index, (glyph, width)) in glyphs.enumerate() {
243 buf.set_stringn(x, y, glyph, usize::from(width), shade(index, n));
244 x = x.saturating_add(width);
245 }
246}
247
248impl Widget for &Hourglass<'_> {
249 fn render(self, area: Rect, buf: &mut Buffer) {
250 let area = area.intersection(buf.area);
251 if area.is_empty() {
252 return;
253 }
254 let hint = self.hint.filter(|hint| !hint.is_empty());
255 let block = GLASS_ROWS + 1 + u16::from(hint.is_some());
256 if area.width >= WIDTH && area.height >= block {
257 let top = area.y + (area.height - block) / 2;
258 let left = area.x + (area.width - WIDTH) / 2;
259 for (y, line) in (top..).zip(FRAMES[frame_at(self.elapsed)]) {
260 buf.set_stringn(left, y, line, usize::from(WIDTH), self.glass);
261 }
262 self.shimmer(buf, area, top + GLASS_ROWS);
263 if let Some(hint) = hint {
264 centred(buf, area, top + GLASS_ROWS + 1, hint, |_, _| self.muted);
265 }
266 return;
267 }
268 let rows = if hint.is_some() && area.height >= 2 {
269 2
270 } else {
271 1
272 };
273 let top = area.y + (area.height - rows) / 2;
274 self.shimmer(buf, area, top);
275 if let Some(hint) = hint.filter(|_| rows == 2) {
276 centred(buf, area, top + 1, hint, |_, _| self.muted);
277 }
278 }
279}
280
281impl Widget for Hourglass<'_> {
282 fn render(self, area: Rect, buf: &mut Buffer) {
283 (&self).render(area, buf);
284 }
285}