Skip to main content

omp_tui/components/
spinner.rs

1//! Indeterminate activity indicator backing the `<spinner>` markup tag.
2
3use omp_core::Str;
4
5use crate::{
6	component::{Component, PaintCtx, Slot, next_slot},
7	context::UiContext,
8	frame::Rect,
9	props::{Prop, PropValue, Props},
10	rich::cell_width,
11};
12
13/// An animated one-cell spinner with an optional trailing label.
14///
15/// The reference consumer of the animation clock: it paints the
16/// [`Frames`] glyph for [`PaintCtx::now`] and requests its next repaint
17/// with [`PaintCtx::wake`], so it animates only while presented and stops
18/// costing anything the moment it leaves the tree.
19pub struct Spinner {
20	props: Props,
21	slot:  Slot,
22	label: Str,
23}
24
25impl Spinner {
26	/// Creates a bare spinner.
27	pub fn new() -> Self {
28		Self { props: Props::new(), slot: next_slot(), label: Str::default() }
29	}
30
31	/// Sets the text following the spinner glyph.
32	pub fn label(mut self, label: impl Into<Str>) -> Self {
33		self.label = label.into();
34		self
35	}
36
37	/// Sets one spinner property.
38	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
39		self.props.set(prop, value);
40		self
41	}
42}
43
44impl Default for Spinner {
45	fn default() -> Self {
46		Self::new()
47	}
48}
49
50impl Component for Spinner {
51	fn props(&self) -> &Props {
52		&self.props
53	}
54
55	fn props_mut(&mut self) -> &mut Props {
56		&mut self.props
57	}
58
59	fn slot(&self) -> Slot {
60		self.slot
61	}
62
63	fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
64		let natural = if self.label.is_empty() {
65			1
66		} else {
67			cell_width(&self.label).saturating_add(2)
68		};
69		(1, natural)
70	}
71
72	fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
73		1
74	}
75
76	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
77		if rect.y >= pc.clip || rect.width == 0 {
78			return;
79		}
80		let frames = pc.ctx.charset.spinner();
81		let style = self.props.style(&pc.ctx.theme);
82		let mut column = pc.frame.put(rect.x, rect.y, frames.at(pc.now), style);
83		if !self.label.is_empty() {
84			column = pc.frame.put(column, rect.y, " ", style);
85			pc.frame.put(column, rect.y, &self.label, style);
86		}
87		pc.wake(self.slot, frames.next_change(pc.now));
88	}
89
90	fn set_text(&mut self, _ctx: &UiContext, text: Str) -> bool {
91		if self.label == text {
92			return false;
93		}
94		self.label = text;
95		true
96	}
97}
98
99#[cfg(test)]
100mod tests {
101	use std::time::Duration;
102
103	use super::*;
104	use crate::{context::Charset, test_support::frame_row_text, ui::Ui};
105
106	#[test]
107	fn ticking_advances_the_glyph_and_reschedules() {
108		let mut ui = Ui::from_root(Spinner::new().label("busy"), 10, UiContext::default());
109		assert_eq!(frame_row_text(ui.frame(), 0), "⠋ busy");
110		assert_eq!(ui.next_wake(), Some(Duration::from_millis(80)));
111
112		assert!(!ui.tick(Duration::from_millis(79)), "no deadline is due yet");
113		assert!(ui.tick(Duration::from_millis(80)));
114		assert_eq!(frame_row_text(ui.frame(), 0), "⠙ busy");
115		assert_eq!(ui.next_wake(), Some(Duration::from_millis(160)));
116	}
117
118	#[test]
119	fn ascii_charset_uses_spoke_frames() {
120		let ctx = UiContext { charset: Charset::Ascii, ..UiContext::default() };
121		let mut ui = Ui::from_root(Spinner::new(), 4, ctx);
122		assert_eq!(frame_row_text(ui.frame(), 0), "|");
123		ui.tick(Duration::from_millis(120));
124		assert_eq!(frame_row_text(ui.frame(), 0), "/");
125	}
126
127	#[test]
128	fn set_text_replaces_the_label() {
129		let mut ui = Ui::from_root(
130			Spinner::new().label("indexing").with(Prop::Id, "spin"),
131			24,
132			UiContext::default(),
133		);
134		assert!(ui.set_text("spin", "linking"));
135		assert_eq!(frame_row_text(ui.frame(), 0), "⠋ linking");
136		assert!(!ui.set_text("spin", "linking"), "unchanged text reports no update");
137	}
138}