Skip to main content

nano_cursor/
cursor.rs

1//! The cursor itself: a post-pass that styles the cell under the caret.
2
3use crate::color::contrast_ink;
4use ratatui::buffer::Buffer;
5use ratatui::layout::{Position, Rect};
6use ratatui::style::Color;
7use std::time::{Duration, Instant};
8use unicode_width::UnicodeWidthStr;
9
10/// What the cursor looks like.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub enum Shape {
13    /// Fills the cell. The glyph underneath survives, re-inked for contrast.
14    #[default]
15    Block,
16    /// A vertical bar at the left edge of the cell.
17    ///
18    /// Lossy: the bar replaces the glyph in that cell rather than sitting
19    /// between cells the way a terminal's own bar cursor does. In an input
20    /// widget the caret usually rests on a blank cell, so this rarely shows.
21    Bar,
22    /// Underlines the cell, leaving glyph, foreground and background alone.
23    Underline,
24}
25
26/// The colour of the glyph under a [`Shape::Block`] cursor.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum Ink {
29    /// Pick automatically, so the glyph stays readable on the cursor colour.
30    #[default]
31    Auto,
32    /// Always this colour.
33    Fixed(Color),
34}
35
36/// Whether the cursor blinks, and on whose clock.
37///
38/// `since` is the caller's, not the crate's. A library-owned epoch would be
39/// hidden state and untestable timing, and real terminals restart the blink
40/// on each keystroke — which a caller that already knows its last key press
41/// gets for free by passing it here.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum Blink {
44    /// Always visible.
45    #[default]
46    Off,
47    /// Alternates every `period`, measured from `since`.
48    On {
49        /// Half-cycle: how long the cursor stays on, then off.
50        period: Duration,
51        /// When the current blink cycle started.
52        since: Instant,
53    },
54}
55
56impl Blink {
57    /// Whether the cursor is visible at `now`, and how long until that flips.
58    fn phase(self, now: Instant) -> (bool, Option<Duration>) {
59        let Blink::On { period, since } = self else {
60            return (true, None);
61        };
62        if period.is_zero() {
63            return (true, None);
64        }
65        let elapsed = now.saturating_duration_since(since);
66        let cycles = elapsed.as_nanos() / period.as_nanos();
67        let visible = cycles.is_multiple_of(2);
68        let consumed = period * u32::try_from(cycles).unwrap_or(u32::MAX);
69        (
70            visible,
71            Some(period.saturating_sub(elapsed.saturating_sub(consumed))),
72        )
73    }
74}
75
76/// What the caller must do after rendering.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78#[must_use = "the real cursor must be positioned here or IME placement breaks"]
79pub struct Report {
80    /// Where to put the real terminal cursor, which the caller should keep
81    /// hidden. Terminals place the IME candidate window and screen-reader
82    /// focus at the position they track, whether or not a cursor is drawn.
83    pub position: Position,
84    /// How long until the cursor's appearance next changes, when it blinks.
85    /// `None` when steady. A caller driving its own event loop should shorten
86    /// its poll timeout to this.
87    pub redraw_after: Option<Duration>,
88}
89
90/// A cursor to paint into a buffer.
91#[derive(Debug, Clone, Copy)]
92pub struct Cursor {
93    color: Color,
94    shape: Shape,
95    ink: Ink,
96    blink: Blink,
97}
98
99impl Cursor {
100    /// A block cursor in `color` that does not blink.
101    #[must_use]
102    pub fn new(color: Color) -> Self {
103        Self {
104            color,
105            shape: Shape::default(),
106            ink: Ink::default(),
107            blink: Blink::default(),
108        }
109    }
110
111    /// Sets the shape.
112    #[must_use]
113    pub fn shape(mut self, shape: Shape) -> Self {
114        self.shape = shape;
115        self
116    }
117
118    /// Sets how the glyph under a [`Shape::Block`] cursor is coloured.
119    #[must_use]
120    pub fn ink(mut self, ink: Ink) -> Self {
121        self.ink = ink;
122        self
123    }
124
125    /// Sets whether the cursor blinks.
126    #[must_use]
127    pub fn blink(mut self, blink: Blink) -> Self {
128        self.blink = blink;
129        self
130    }
131
132    /// Paints the cursor at `pos`, clamped into `area`.
133    ///
134    /// Must run *after* the text has been rendered into `buf`: the contrast
135    /// pick reads the glyph already in the cell, and a wide glyph's trailing
136    /// cell has to be there to be found.
137    pub fn render(self, area: Rect, buf: &mut Buffer, pos: Position, now: Instant) -> Report {
138        let position = clamp(area, pos);
139        // Clamping is for the *report* only. A caret outside the area paints
140        // nothing: clamping first and then painting would drop a block on an
141        // unrelated cell at the edge.
142        if area.is_empty() || !area.contains(pos) {
143            return Report {
144                position,
145                redraw_after: None,
146            };
147        }
148        let (visible, redraw_after) = self.blink.phase(now);
149        if !visible {
150            return Report {
151                position,
152                redraw_after,
153            };
154        }
155        let width = buf
156            .cell(position)
157            .map_or(1, |c| UnicodeWidthStr::width(c.symbol()).max(1));
158        for dx in 0..u16::try_from(width).unwrap_or(1) {
159            let p = Position::new(position.x + dx, position.y);
160            if !area.contains(p) {
161                break;
162            }
163            self.paint(buf, p);
164        }
165        Report {
166            position,
167            redraw_after,
168        }
169    }
170
171    /// Styles one cell.
172    fn paint(self, buf: &mut Buffer, p: Position) {
173        let Some(cell) = buf.cell_mut(p) else { return };
174        match self.shape {
175            Shape::Block => {
176                let ink = match self.ink {
177                    Ink::Auto => contrast_ink(self.color),
178                    Ink::Fixed(c) => c,
179                };
180                cell.set_bg(self.color).set_fg(ink);
181            }
182            Shape::Bar => {
183                cell.set_symbol("\u{258f}").set_fg(self.color);
184            }
185            Shape::Underline => {
186                use ratatui::style::{Modifier, Style};
187                cell.set_style(
188                    Style::default()
189                        .add_modifier(Modifier::UNDERLINED)
190                        .underline_color(self.color),
191                );
192            }
193        }
194    }
195}
196
197/// The nearest position inside `area`.
198fn clamp(area: Rect, pos: Position) -> Position {
199    if area.is_empty() {
200        return Position::new(area.x, area.y);
201    }
202    Position::new(
203        pos.x.clamp(area.x, area.right() - 1),
204        pos.y.clamp(area.y, area.bottom() - 1),
205    )
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    const GREEN: Color = Color::Rgb(0x87, 0xaf, 0x5f);
213
214    fn area() -> Rect {
215        Rect::new(0, 0, 6, 1)
216    }
217
218    #[test]
219    fn block_sets_the_background_and_keeps_the_glyph() {
220        let mut buf = Buffer::with_lines(vec!["abcdef"]);
221        let report =
222            Cursor::new(GREEN).render(area(), &mut buf, Position::new(2, 0), Instant::now());
223        let cell = buf.cell(Position::new(2, 0)).unwrap();
224        assert_eq!(cell.symbol(), "c");
225        assert_eq!(cell.bg, GREEN);
226        assert_eq!(report.position, Position::new(2, 0));
227        assert_eq!(report.redraw_after, None);
228    }
229
230    #[test]
231    fn block_flips_a_same_coloured_glyph_to_readable_ink() {
232        let mut buf = Buffer::with_lines(vec!["abcdef"]);
233        buf.cell_mut(Position::new(2, 0)).unwrap().set_fg(GREEN);
234        let _ = Cursor::new(GREEN).render(area(), &mut buf, Position::new(2, 0), Instant::now());
235        let cell = buf.cell(Position::new(2, 0)).unwrap();
236        assert_ne!(cell.fg, GREEN, "the glyph would be invisible");
237        assert_eq!(cell.fg, Color::Rgb(0x11, 0x11, 0x11));
238    }
239
240    #[test]
241    fn fixed_ink_overrides_the_contrast_pick() {
242        let mut buf = Buffer::with_lines(vec!["abcdef"]);
243        let _ = Cursor::new(GREEN)
244            .ink(Ink::Fixed(Color::Rgb(1, 2, 3)))
245            .render(area(), &mut buf, Position::new(0, 0), Instant::now());
246        assert_eq!(
247            buf.cell(Position::new(0, 0)).unwrap().fg,
248            Color::Rgb(1, 2, 3)
249        );
250    }
251
252    #[test]
253    fn a_wide_glyph_gets_both_of_its_cells() {
254        // The CJK glyph occupies columns 0 and 1; ratatui stores the trailing
255        // cell as an empty symbol. Covering only the first tears the row.
256        let mut buf = Buffer::with_lines(vec!["\u{4f60}abcd"]);
257        let _ = Cursor::new(GREEN).render(area(), &mut buf, Position::new(0, 0), Instant::now());
258        assert_eq!(buf.cell(Position::new(0, 0)).unwrap().bg, GREEN);
259        assert_eq!(
260            buf.cell(Position::new(1, 0)).unwrap().bg,
261            GREEN,
262            "spacer cell left behind"
263        );
264    }
265
266    #[test]
267    fn a_position_outside_the_area_paints_nothing_but_still_reports() {
268        let mut buf = Buffer::with_lines(vec!["abcdef"]);
269        let report =
270            Cursor::new(GREEN).render(area(), &mut buf, Position::new(99, 0), Instant::now());
271        assert_eq!(buf.cell(Position::new(5, 0)).unwrap().bg, Color::Reset);
272        assert_eq!(
273            report.position,
274            Position::new(5, 0),
275            "clamped into the area"
276        );
277    }
278
279    #[test]
280    fn underline_preserves_the_cell_and_colours_the_line() {
281        use ratatui::style::Modifier;
282        let mut buf = Buffer::with_lines(vec!["abcdef"]);
283        buf.cell_mut(Position::new(2, 0))
284            .unwrap()
285            .set_fg(Color::Blue);
286        let _ = Cursor::new(GREEN).shape(Shape::Underline).render(
287            area(),
288            &mut buf,
289            Position::new(2, 0),
290            Instant::now(),
291        );
292        let cell = buf.cell(Position::new(2, 0)).unwrap();
293        assert_eq!(cell.symbol(), "c", "glyph must survive");
294        assert_eq!(cell.fg, Color::Blue, "foreground must survive");
295        assert_eq!(cell.bg, Color::Reset, "background must survive");
296        assert!(cell.modifier.contains(Modifier::UNDERLINED));
297        assert_eq!(cell.underline_color, GREEN);
298    }
299
300    #[test]
301    fn blink_paints_in_the_on_phase_and_skips_the_off_phase() {
302        let since = Instant::now();
303        let period = Duration::from_millis(500);
304        let mut on = Buffer::with_lines(vec!["abcdef"]);
305        let _ = Cursor::new(GREEN)
306            .blink(Blink::On { period, since })
307            .render(
308                area(),
309                &mut on,
310                Position::new(0, 0),
311                since + Duration::from_millis(100),
312            );
313        assert_eq!(
314            on.cell(Position::new(0, 0)).unwrap().bg,
315            GREEN,
316            "first half is on"
317        );
318
319        let mut off = Buffer::with_lines(vec!["abcdef"]);
320        let _ = Cursor::new(GREEN)
321            .blink(Blink::On { period, since })
322            .render(
323                area(),
324                &mut off,
325                Position::new(0, 0),
326                since + Duration::from_millis(600),
327            );
328        assert_eq!(
329            off.cell(Position::new(0, 0)).unwrap().bg,
330            Color::Reset,
331            "second half is off"
332        );
333    }
334
335    #[test]
336    fn redraw_after_counts_down_to_the_next_phase_boundary() {
337        let since = Instant::now();
338        let period = Duration::from_millis(500);
339        let mut buf = Buffer::with_lines(vec!["abcdef"]);
340        let report = Cursor::new(GREEN)
341            .blink(Blink::On { period, since })
342            .render(
343                area(),
344                &mut buf,
345                Position::new(0, 0),
346                since + Duration::from_millis(400),
347            );
348        assert_eq!(report.redraw_after, Some(Duration::from_millis(100)));
349    }
350
351    #[test]
352    fn a_steady_cursor_asks_for_no_redraw() {
353        let mut buf = Buffer::with_lines(vec!["abcdef"]);
354        let report =
355            Cursor::new(GREEN).render(area(), &mut buf, Position::new(0, 0), Instant::now());
356        assert_eq!(report.redraw_after, None);
357    }
358
359    #[test]
360    fn bar_replaces_the_glyph_which_is_documented_and_lossy() {
361        let mut buf = Buffer::with_lines(vec!["abcdef"]);
362        let _ = Cursor::new(GREEN).shape(Shape::Bar).render(
363            area(),
364            &mut buf,
365            Position::new(2, 0),
366            Instant::now(),
367        );
368        let cell = buf.cell(Position::new(2, 0)).unwrap();
369        assert_eq!(cell.symbol(), "\u{258f}");
370        assert_eq!(cell.fg, GREEN);
371        assert_eq!(cell.bg, Color::Reset, "the bar tints ink, not ground");
372    }
373}