Skip to main content

bubbles/
cursor.rs

1//! Cursor component with blinking support.
2//!
3//! This module provides a cursor component that can be used in text input
4//! components. It supports multiple modes including blinking, static, and hidden.
5//!
6//! # Example
7//!
8//! ```rust
9//! use bubbles::cursor::{Cursor, Mode};
10//!
11//! let mut cursor = Cursor::new();
12//! cursor.set_char("_");
13//! cursor.set_mode(Mode::Static);
14//!
15//! // In your view function:
16//! let rendered = cursor.view();
17//! ```
18
19use std::sync::atomic::{AtomicU64, Ordering};
20use std::time::Duration;
21
22use bubbletea::{Cmd, Message, Model};
23use lipgloss::Style;
24
25/// Default blink speed (530ms).
26const DEFAULT_BLINK_SPEED: Duration = Duration::from_millis(530);
27
28/// Global ID counter for cursor instances.
29static NEXT_ID: AtomicU64 = AtomicU64::new(1);
30
31fn next_id() -> u64 {
32    NEXT_ID.fetch_add(1, Ordering::Relaxed)
33}
34
35/// Cursor display mode.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum Mode {
38    /// Cursor blinks on and off.
39    #[default]
40    Blink,
41    /// Cursor is always visible.
42    Static,
43    /// Cursor is hidden.
44    Hide,
45}
46
47impl std::fmt::Display for Mode {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            Self::Blink => write!(f, "blink"),
51            Self::Static => write!(f, "static"),
52            Self::Hide => write!(f, "hidden"),
53        }
54    }
55}
56
57/// Message to initialize cursor blinking.
58#[derive(Debug, Clone, Copy)]
59pub struct InitialBlinkMsg;
60
61/// Message signaling that the cursor should toggle its blink state.
62#[derive(Debug, Clone, Copy)]
63pub struct BlinkMsg {
64    /// The cursor ID this message is for.
65    pub id: u64,
66    /// The blink tag to ensure message ordering.
67    pub tag: u64,
68}
69
70/// Message sent when a blink operation is canceled.
71#[derive(Debug, Clone, Copy)]
72pub struct BlinkCanceledMsg;
73
74/// A cursor component for text input.
75#[derive(Debug, Clone)]
76pub struct Cursor {
77    /// The blink speed.
78    pub blink_speed: Duration,
79    /// Style for the cursor block.
80    pub style: Style,
81    /// Style for text when cursor is hidden (blinking off).
82    pub text_style: Style,
83
84    // Internal state
85    char: String,
86    id: u64,
87    focus: bool,
88    blink: bool,
89    blink_tag: u64,
90    mode: Mode,
91}
92
93impl Default for Cursor {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99impl Cursor {
100    /// Creates a new cursor with default settings.
101    #[must_use]
102    pub fn new() -> Self {
103        Self {
104            blink_speed: DEFAULT_BLINK_SPEED,
105            style: Style::new(),
106            text_style: Style::new(),
107            char: String::new(),
108            id: next_id(),
109            focus: false,
110            blink: true,
111            blink_tag: 0,
112            mode: Mode::Blink,
113        }
114    }
115
116    /// Returns the cursor's unique ID.
117    #[must_use]
118    pub fn id(&self) -> u64 {
119        self.id
120    }
121
122    /// Returns the current cursor mode.
123    #[must_use]
124    pub fn mode(&self) -> Mode {
125        self.mode
126    }
127
128    /// Sets the cursor mode.
129    ///
130    /// Returns a command to start blinking if the mode is `Blink`.
131    pub fn set_mode(&mut self, mode: Mode) -> Option<Cmd> {
132        self.mode = mode;
133        self.blink = mode == Mode::Hide || !self.focus;
134
135        if mode == Mode::Blink {
136            Some(blink_cmd())
137        } else {
138            None
139        }
140    }
141
142    /// Sets the character displayed under the cursor.
143    pub fn set_char(&mut self, c: &str) {
144        self.char = c.to_string();
145    }
146
147    /// Returns the character under the cursor.
148    #[must_use]
149    pub fn char(&self) -> &str {
150        &self.char
151    }
152
153    /// Returns whether the cursor is currently focused.
154    #[must_use]
155    pub fn focused(&self) -> bool {
156        self.focus
157    }
158
159    /// Returns whether the cursor is currently in its "off" blink state.
160    #[must_use]
161    pub fn is_blinking_off(&self) -> bool {
162        self.blink
163    }
164
165    /// Focuses the cursor, allowing it to blink if in blink mode.
166    ///
167    /// Returns a command to start blinking.
168    pub fn focus(&mut self) -> Option<Cmd> {
169        self.focus = true;
170        self.blink = self.mode == Mode::Hide;
171
172        if self.mode == Mode::Blink && self.focus {
173            Some(self.blink_tick_cmd())
174        } else {
175            None
176        }
177    }
178
179    /// Blurs (unfocuses) the cursor.
180    pub fn blur(&mut self) {
181        self.focus = false;
182        self.blink = true;
183    }
184
185    /// Creates a command to trigger the next blink.
186    fn blink_tick_cmd(&mut self) -> Cmd {
187        if self.mode != Mode::Blink {
188            return Cmd::new(|| Message::new(BlinkCanceledMsg));
189        }
190
191        self.blink_tag = self.blink_tag.wrapping_add(1);
192        let id = self.id;
193        let tag = self.blink_tag;
194        let speed = self.blink_speed;
195
196        Cmd::new(move || {
197            std::thread::sleep(speed);
198            Message::new(BlinkMsg { id, tag })
199        })
200    }
201
202    /// Updates the cursor state based on incoming messages.
203    pub fn update(&mut self, msg: Message) -> Option<Cmd> {
204        // Handle initial blink message
205        if msg.is::<InitialBlinkMsg>() {
206            if self.mode != Mode::Blink || !self.focus {
207                return None;
208            }
209            return Some(self.blink_tick_cmd());
210        }
211
212        // Handle focus message
213        if msg.is::<bubbletea::FocusMsg>() {
214            return self.focus();
215        }
216
217        // Handle blur message
218        if msg.is::<bubbletea::BlurMsg>() {
219            self.blur();
220            return None;
221        }
222
223        // Handle blink message
224        if let Some(blink_msg) = msg.downcast_ref::<BlinkMsg>() {
225            // Is this model blink-able?
226            if self.mode != Mode::Blink || !self.focus {
227                return None;
228            }
229
230            // Were we expecting this blink message?
231            if blink_msg.id != self.id || blink_msg.tag != self.blink_tag {
232                return None;
233            }
234
235            // Toggle blink state
236            self.blink = !self.blink;
237            return Some(self.blink_tick_cmd());
238        }
239
240        // Handle blink canceled (no-op)
241        if msg.is::<BlinkCanceledMsg>() {
242            return None;
243        }
244
245        None
246    }
247
248    /// Renders the cursor.
249    #[must_use]
250    pub fn view(&self) -> String {
251        if self.blink {
252            // Cursor is in "off" state, show normal text
253            self.text_style.clone().inline().render(&self.char)
254        } else {
255            // Cursor is in "on" state, show reversed
256            self.style.clone().inline().reverse().render(&self.char)
257        }
258    }
259}
260
261/// Creates a command to initialize cursor blinking.
262#[must_use]
263pub fn blink_cmd() -> Cmd {
264    Cmd::new(|| Message::new(InitialBlinkMsg))
265}
266
267impl Model for Cursor {
268    /// Initialize the cursor and return a blink command if in blink mode and focused.
269    fn init(&self) -> Option<Cmd> {
270        if self.mode == Mode::Blink && self.focus {
271            Some(blink_cmd())
272        } else {
273            None
274        }
275    }
276
277    /// Update the cursor state based on incoming messages.
278    ///
279    /// Handles:
280    /// - `InitialBlinkMsg` - Start blinking if focused and in blink mode
281    /// - `FocusMsg` - Focus the cursor and start blinking
282    /// - `BlurMsg` - Blur the cursor and stop blinking
283    /// - `BlinkMsg` - Toggle blink state and schedule next blink
284    fn update(&mut self, msg: Message) -> Option<Cmd> {
285        Cursor::update(self, msg)
286    }
287
288    /// Render the cursor.
289    ///
290    /// Returns the cursor character styled appropriately based on blink state.
291    fn view(&self) -> String {
292        Cursor::view(self)
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn test_cursor_new() {
302        let cursor = Cursor::new();
303        assert_eq!(cursor.mode(), Mode::Blink);
304        assert!(!cursor.focused());
305        assert!(cursor.is_blinking_off());
306    }
307
308    #[test]
309    fn test_cursor_unique_ids() {
310        let cursor1 = Cursor::new();
311        let cursor2 = Cursor::new();
312        assert_ne!(cursor1.id(), cursor2.id());
313    }
314
315    #[test]
316    fn test_cursor_focus_blur() {
317        let mut cursor = Cursor::new();
318        assert!(!cursor.focused());
319
320        cursor.focus();
321        assert!(cursor.focused());
322        assert!(!cursor.is_blinking_off()); // Cursor should be visible when focused
323
324        cursor.blur();
325        assert!(!cursor.focused());
326        assert!(cursor.is_blinking_off()); // Cursor hidden when blurred
327    }
328
329    #[test]
330    fn test_cursor_mode_static() {
331        let mut cursor = Cursor::new();
332        cursor.set_mode(Mode::Static);
333        assert_eq!(cursor.mode(), Mode::Static);
334    }
335
336    #[test]
337    fn test_cursor_mode_hide() {
338        let mut cursor = Cursor::new();
339        cursor.set_mode(Mode::Hide);
340        assert_eq!(cursor.mode(), Mode::Hide);
341        assert!(cursor.is_blinking_off());
342    }
343
344    #[test]
345    fn test_cursor_set_char() {
346        let mut cursor = Cursor::new();
347        cursor.set_char("_");
348        assert_eq!(cursor.char(), "_");
349    }
350
351    #[test]
352    fn test_cursor_view() {
353        let mut cursor = Cursor::new();
354        cursor.set_char("a");
355
356        // When blinking off (default), should render with text style
357        let view = cursor.view();
358        assert!(!view.is_empty());
359    }
360
361    fn strip_ansi(s: &str) -> String {
362        let mut result = String::with_capacity(s.len());
363        let mut in_escape = false;
364        let mut in_csi = false;
365
366        for c in s.chars() {
367            if c == '\x1b' {
368                in_escape = true;
369                in_csi = false;
370                continue;
371            }
372            if in_escape {
373                if c == '[' {
374                    in_csi = true;
375                    continue;
376                }
377                if in_csi {
378                    // CSI sequences end with a byte in 0x40-0x7E ('@' through '~')
379                    if ('@'..='~').contains(&c) {
380                        in_escape = false;
381                        in_csi = false;
382                    }
383                    continue;
384                }
385                // Non-CSI escape sequence
386                in_escape = false;
387                continue;
388            }
389            result.push(c);
390        }
391
392        result
393    }
394
395    #[test]
396    fn test_cursor_view_inline_removes_padding() {
397        let mut cursor = Cursor::new();
398        cursor.set_char("x");
399
400        cursor.text_style = Style::new().padding(1);
401        cursor.blink = true;
402        assert_eq!(cursor.view(), "x");
403
404        cursor.style = Style::new().padding(1);
405        cursor.blink = false;
406        assert_eq!(strip_ansi(&cursor.view()), "x");
407    }
408
409    #[test]
410    fn test_mode_display() {
411        assert_eq!(Mode::Blink.to_string(), "blink");
412        assert_eq!(Mode::Static.to_string(), "static");
413        assert_eq!(Mode::Hide.to_string(), "hidden");
414    }
415
416    #[test]
417    fn test_blink_msg_routing() {
418        let mut cursor1 = Cursor::new();
419        let mut cursor2 = Cursor::new();
420
421        cursor1.focus();
422        cursor2.focus();
423
424        // Message for cursor1 shouldn't affect cursor2
425        let msg = Message::new(BlinkMsg {
426            id: cursor1.id(),
427            tag: cursor1.blink_tag,
428        });
429
430        let cmd2 = cursor2.update(msg);
431        assert!(cmd2.is_none()); // cursor2 should ignore cursor1's message
432    }
433
434    // Model trait implementation tests
435    #[test]
436    fn test_model_init_unfocused() {
437        let cursor = Cursor::new();
438        // Unfocused cursor should not return init command
439        let cmd = Model::init(&cursor);
440        assert!(cmd.is_none());
441    }
442
443    #[test]
444    fn test_model_init_focused_blink() {
445        let mut cursor = Cursor::new();
446        cursor.focus();
447        // Focused cursor in blink mode should return init command
448        let cmd = Model::init(&cursor);
449        assert!(cmd.is_some());
450    }
451
452    #[test]
453    fn test_model_init_focused_static() {
454        let mut cursor = Cursor::new();
455        cursor.set_mode(Mode::Static);
456        cursor.focus();
457        // Focused cursor in static mode should not return init command
458        let cmd = Model::init(&cursor);
459        assert!(cmd.is_none());
460    }
461
462    #[test]
463    fn test_model_view() {
464        let mut cursor = Cursor::new();
465        cursor.set_char("x");
466        // Model::view should return same result as Cursor::view
467        let model_view = Model::view(&cursor);
468        let cursor_view = Cursor::view(&cursor);
469        assert_eq!(model_view, cursor_view);
470    }
471}