1use std::sync::atomic::{AtomicU64, Ordering};
20use std::time::Duration;
21
22use bubbletea::{Cmd, Message, Model};
23use lipgloss::Style;
24
25const DEFAULT_BLINK_SPEED: Duration = Duration::from_millis(530);
27
28static NEXT_ID: AtomicU64 = AtomicU64::new(1);
30
31fn next_id() -> u64 {
32 NEXT_ID.fetch_add(1, Ordering::Relaxed)
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum Mode {
38 #[default]
40 Blink,
41 Static,
43 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#[derive(Debug, Clone, Copy)]
59pub struct InitialBlinkMsg;
60
61#[derive(Debug, Clone, Copy)]
63pub struct BlinkMsg {
64 pub id: u64,
66 pub tag: u64,
68}
69
70#[derive(Debug, Clone, Copy)]
72pub struct BlinkCanceledMsg;
73
74#[derive(Debug, Clone)]
76pub struct Cursor {
77 pub blink_speed: Duration,
79 pub style: Style,
81 pub text_style: Style,
83
84 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 #[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 #[must_use]
118 pub fn id(&self) -> u64 {
119 self.id
120 }
121
122 #[must_use]
124 pub fn mode(&self) -> Mode {
125 self.mode
126 }
127
128 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 pub fn set_char(&mut self, c: &str) {
144 self.char = c.to_string();
145 }
146
147 #[must_use]
149 pub fn char(&self) -> &str {
150 &self.char
151 }
152
153 #[must_use]
155 pub fn focused(&self) -> bool {
156 self.focus
157 }
158
159 #[must_use]
161 pub fn is_blinking_off(&self) -> bool {
162 self.blink
163 }
164
165 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 pub fn blur(&mut self) {
181 self.focus = false;
182 self.blink = true;
183 }
184
185 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 pub fn update(&mut self, msg: Message) -> Option<Cmd> {
204 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 if msg.is::<bubbletea::FocusMsg>() {
214 return self.focus();
215 }
216
217 if msg.is::<bubbletea::BlurMsg>() {
219 self.blur();
220 return None;
221 }
222
223 if let Some(blink_msg) = msg.downcast_ref::<BlinkMsg>() {
225 if self.mode != Mode::Blink || !self.focus {
227 return None;
228 }
229
230 if blink_msg.id != self.id || blink_msg.tag != self.blink_tag {
232 return None;
233 }
234
235 self.blink = !self.blink;
237 return Some(self.blink_tick_cmd());
238 }
239
240 if msg.is::<BlinkCanceledMsg>() {
242 return None;
243 }
244
245 None
246 }
247
248 #[must_use]
250 pub fn view(&self) -> String {
251 if self.blink {
252 self.text_style.clone().inline().render(&self.char)
254 } else {
255 self.style.clone().inline().reverse().render(&self.char)
257 }
258 }
259}
260
261#[must_use]
263pub fn blink_cmd() -> Cmd {
264 Cmd::new(|| Message::new(InitialBlinkMsg))
265}
266
267impl Model for Cursor {
268 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 fn update(&mut self, msg: Message) -> Option<Cmd> {
285 Cursor::update(self, msg)
286 }
287
288 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.blur();
325 assert!(!cursor.focused());
326 assert!(cursor.is_blinking_off()); }
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 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 if ('@'..='~').contains(&c) {
380 in_escape = false;
381 in_csi = false;
382 }
383 continue;
384 }
385 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 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()); }
433
434 #[test]
436 fn test_model_init_unfocused() {
437 let cursor = Cursor::new();
438 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 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 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 let model_view = Model::view(&cursor);
468 let cursor_view = Cursor::view(&cursor);
469 assert_eq!(model_view, cursor_view);
470 }
471}