Skip to main content

pito_hourglass/
lib.rs

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}