1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub enum Shape {
13 #[default]
15 Block,
16 Bar,
22 Underline,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum Ink {
29 #[default]
31 Auto,
32 Fixed(Color),
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum Blink {
44 #[default]
46 Off,
47 On {
49 period: Duration,
51 since: Instant,
53 },
54}
55
56impl Blink {
57 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78#[must_use = "the real cursor must be positioned here or IME placement breaks"]
79pub struct Report {
80 pub position: Position,
84 pub redraw_after: Option<Duration>,
88}
89
90#[derive(Debug, Clone, Copy)]
92pub struct Cursor {
93 color: Color,
94 shape: Shape,
95 ink: Ink,
96 blink: Blink,
97}
98
99impl Cursor {
100 #[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 #[must_use]
113 pub fn shape(mut self, shape: Shape) -> Self {
114 self.shape = shape;
115 self
116 }
117
118 #[must_use]
120 pub fn ink(mut self, ink: Ink) -> Self {
121 self.ink = ink;
122 self
123 }
124
125 #[must_use]
127 pub fn blink(mut self, blink: Blink) -> Self {
128 self.blink = blink;
129 self
130 }
131
132 pub fn render(self, area: Rect, buf: &mut Buffer, pos: Position, now: Instant) -> Report {
138 let position = clamp(area, pos);
139 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 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
197fn 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 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}