omp_tui/components/
spinner.rs1use 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
13pub struct Spinner {
20 props: Props,
21 slot: Slot,
22 label: Str,
23}
24
25impl Spinner {
26 pub fn new() -> Self {
28 Self { props: Props::new(), slot: next_slot(), label: Str::default() }
29 }
30
31 pub fn label(mut self, label: impl Into<Str>) -> Self {
33 self.label = label.into();
34 self
35 }
36
37 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}