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}