bubbletea_widgets/cursor.rs
1//! Cursor component for Bubble Tea-style text inputs.
2//!
3//! This component provides a reusable text cursor for inputs, text areas, and
4//! other widgets that need a caret. It supports blinking, static, and hidden
5//! modes and can be themed via Lip Gloss styles.
6//!
7//! The cursor is typically embedded inside another component (for example the
8//! textarea model) and updated by forwarding messages. It can also be used as a
9//! standalone `bubbletea_rs::Model` for demonstration or tests.
10//!
11//! ### Example
12//! ```rust
13//! use bubbletea_widgets::cursor;
14//! use lipgloss_extras::prelude::*;
15//!
16//! let mut cur = cursor::new();
17//! cur.style = Style::new().reverse(true); // style when the cursor block is shown
18//! cur.text_style = Style::new(); // style for the character underneath when hidden
19//! let _ = cur.focus(); // start blinking
20//! cur.set_char("x");
21//! let _maybe_cmd = cur.set_mode(cursor::Mode::Blink);
22//! let view = cur.view();
23//! assert!(!view.is_empty());
24//! ```
25
26use bubbletea_rs::{tick, Cmd, Model as BubbleTeaModel, Msg};
27use lipgloss_extras::prelude::*;
28use std::sync::atomic::{AtomicUsize, Ordering};
29use std::time::Duration;
30
31// --- Internal ID Management ---
32// Used to ensure that frame messages are only received by the cursor that sent them.
33static LAST_ID: AtomicUsize = AtomicUsize::new(0);
34
35fn next_id() -> usize {
36 LAST_ID.fetch_add(1, Ordering::Relaxed)
37}
38
39const DEFAULT_BLINK_SPEED: Duration = Duration::from_millis(530);
40
41// --- Messages ---
42
43/// Message to start the cursor blinking.
44#[derive(Debug, Clone)]
45pub struct InitialBlinkMsg;
46
47/// Message that signals the cursor should blink.
48#[derive(Debug, Clone)]
49pub struct BlinkMsg {
50 /// Unique identifier of the cursor instance that this blink message targets.
51 pub id: usize,
52 /// Sequence tag to prevent processing stale blink messages.
53 pub tag: usize,
54}
55
56// --- Mode ---
57
58/// Describes the behavior of the cursor.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum Mode {
61 /// The cursor blinks.
62 Blink,
63 /// The cursor is static.
64 Static,
65 /// The cursor is hidden.
66 Hide,
67}
68
69impl std::fmt::Display for Mode {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 write!(
72 f,
73 "{}",
74 match self {
75 Mode::Blink => "blink",
76 Mode::Static => "static",
77 Mode::Hide => "hidden",
78 }
79 )
80 }
81}
82
83// --- Model ---
84
85/// Model is the Bubble Tea model for this cursor element.
86#[derive(Debug, Clone)]
87pub struct Model {
88 /// The speed at which the cursor blinks.
89 pub blink_speed: Duration,
90 /// Style for the cursor when it is visible (blinking "on").
91 pub style: Style,
92 /// Style for the text under the cursor when it is hidden (blinking "off").
93 pub text_style: Style,
94
95 char: String,
96 id: usize,
97 focus: bool,
98 is_off_phase: bool, // When true, cursor is in "off" phase (hidden/showing text style)
99 blink_tag: usize,
100 mode: Mode,
101}
102
103impl Default for Model {
104 /// Creates a new model with default settings.
105 fn default() -> Self {
106 Self {
107 blink_speed: DEFAULT_BLINK_SPEED,
108 style: Style::new(),
109 text_style: Style::new(),
110 char: " ".to_string(),
111 id: next_id(),
112 focus: false,
113 is_off_phase: true, // Start in off phase (showing text style)
114 blink_tag: 0,
115 mode: Mode::Blink,
116 }
117 }
118}
119
120impl Model {
121 /// Creates a new model with default settings.
122 pub fn new() -> Self {
123 Self::default()
124 }
125
126 /// Sets the visibility of the cursor.
127 pub fn set_visible(&mut self, visible: bool) {
128 self.is_off_phase = !visible;
129 }
130
131 /// Update is the Bubble Tea update loop. It handles cursor-related messages.
132 /// This is not a `bubbletea_rs::Model` implementation because the cursor is
133 /// a sub-component managed by another model.
134 pub fn update(&mut self, msg: &Msg) -> Option<Cmd> {
135 if msg.downcast_ref::<InitialBlinkMsg>().is_some() {
136 if self.mode != Mode::Blink || !self.focus {
137 return None;
138 }
139 return self.blink_cmd();
140 }
141
142 if let Some(blink_msg) = msg.downcast_ref::<BlinkMsg>() {
143 // Is this model blink-able?
144 if self.mode != Mode::Blink || !self.focus {
145 return None;
146 }
147
148 // Were we expecting this blink message?
149 if blink_msg.id != self.id || blink_msg.tag != self.blink_tag {
150 return None;
151 }
152
153 self.is_off_phase = !self.is_off_phase;
154 return self.blink_cmd();
155 }
156
157 None
158 }
159
160 /// Returns the model's cursor mode.
161 pub fn mode(&self) -> Mode {
162 self.mode
163 }
164
165 /// Sets the model's cursor mode. This method returns a command.
166 pub fn set_mode(&mut self, mode: Mode) -> Option<Cmd> {
167 self.mode = mode;
168 self.is_off_phase = self.mode == Mode::Hide || !self.focus;
169 if mode == Mode::Blink {
170 return Some(blink());
171 }
172 None
173 }
174
175 /// Creates a command to schedule the next blink.
176 fn blink_cmd(&mut self) -> Option<Cmd> {
177 if self.mode != Mode::Blink {
178 return None;
179 }
180
181 self.blink_tag += 1;
182 let tag = self.blink_tag;
183 let id = self.id;
184 let speed = self.blink_speed;
185
186 Some(tick(speed, move |_| Box::new(BlinkMsg { id, tag }) as Msg))
187 }
188
189 /// Focuses the cursor to allow it to blink if desired.
190 pub fn focus(&mut self) -> Option<Cmd> {
191 self.focus = true;
192 self.is_off_phase = self.mode == Mode::Hide; // Show the cursor unless we've explicitly hidden it
193 if self.mode == Mode::Blink && self.focus {
194 return self.blink_cmd();
195 }
196 None
197 }
198
199 /// Blurs the cursor.
200 pub fn blur(&mut self) {
201 self.focus = false;
202 self.is_off_phase = true;
203 }
204
205 /// Check if cursor is focused
206 pub fn focused(&self) -> bool {
207 self.focus
208 }
209
210 /// Sets the character under the cursor.
211 pub fn set_char(&mut self, s: &str) {
212 self.char = s.to_string();
213 }
214
215 /// Renders the cursor.
216 pub fn view(&self) -> String {
217 if self.mode == Mode::Hide || self.is_off_phase {
218 // When in off phase, we show the text style (cursor is hidden)
219 return self.text_style.clone().inline(true).render(&self.char);
220 }
221 // When in on phase, we show the cursor style (reversed)
222 self.style
223 .clone()
224 .inline(true)
225 .reverse(true)
226 .render(&self.char)
227 }
228}
229
230// Optional: Implement BubbleTeaModel for standalone use (though cursor is typically a sub-component)
231impl BubbleTeaModel for Model {
232 fn init() -> (Self, Option<Cmd>) {
233 let model = Self::new();
234 (model, Some(blink()))
235 }
236
237 fn update(&mut self, msg: Msg) -> Option<Cmd> {
238 self.update(&msg)
239 }
240
241 fn view(&self) -> String {
242 self.view()
243 }
244}
245
246/// A command to initialize cursor blinking.
247pub fn blink() -> Cmd {
248 tick(Duration::from_millis(0), |_| {
249 Box::new(InitialBlinkMsg) as Msg
250 })
251}
252
253/// Create a new cursor model. Equivalent to Model::new().
254pub fn new() -> Model {
255 Model::new()
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 // This test verifies that the tag captured in a blink command's message
263 // is the tag value at the time of command creation, ensuring no race conditions
264 // Note: The original Go test used goroutines to test race conditions, but
265 // since Rust's blink_cmd captures values by move, race conditions are prevented by design
266 #[test]
267 fn test_blink_cmd_tag_captured_no_race() {
268 let mut m = Model::new();
269 m.blink_speed = Duration::from_millis(10);
270 m.mode = Mode::Blink;
271 m.focus = true;
272
273 // First blink command; capture expected tag immediately after creation.
274 let _cmd1 = m.blink_cmd().expect("cmd1");
275 let expected_tag = m.blink_tag; // blink_cmd increments before returning
276 let _expected_id = m.id;
277
278 // Schedule another blink command to mutate blink_tag (simulating what would be a race in Go)
279 let _cmd2 = m.blink_cmd();
280 let new_tag = m.blink_tag;
281
282 // In Rust, the closure in cmd1 captured the values by move when created,
283 // so even though we've created cmd2 and incremented blink_tag,
284 // cmd1 still has the original values
285 assert_ne!(
286 expected_tag, new_tag,
287 "Tags should be different after second blink_cmd"
288 );
289
290 // We can't actually await the Cmd without an async runtime,
291 // but we've verified the key property: that the tag is captured at creation time
292 // The actual message would have id=expected_id and tag=expected_tag when executed
293 }
294}