use crate::color::contrast_ink;
use ratatui::buffer::Buffer;
use ratatui::layout::{Position, Rect};
use ratatui::style::Color;
use std::time::{Duration, Instant};
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Shape {
#[default]
Block,
Bar,
Underline,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Ink {
#[default]
Auto,
Fixed(Color),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Blink {
#[default]
Off,
On {
period: Duration,
since: Instant,
},
}
impl Blink {
fn phase(self, now: Instant) -> (bool, Option<Duration>) {
let Blink::On { period, since } = self else {
return (true, None);
};
if period.is_zero() {
return (true, None);
}
let elapsed = now.saturating_duration_since(since);
let cycles = elapsed.as_nanos() / period.as_nanos();
let visible = cycles.is_multiple_of(2);
let consumed = period * u32::try_from(cycles).unwrap_or(u32::MAX);
(
visible,
Some(period.saturating_sub(elapsed.saturating_sub(consumed))),
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[must_use = "the real cursor must be positioned here or IME placement breaks"]
pub struct Report {
pub position: Position,
pub redraw_after: Option<Duration>,
}
#[derive(Debug, Clone, Copy)]
pub struct Cursor {
color: Color,
shape: Shape,
ink: Ink,
blink: Blink,
}
impl Cursor {
#[must_use]
pub fn new(color: Color) -> Self {
Self {
color,
shape: Shape::default(),
ink: Ink::default(),
blink: Blink::default(),
}
}
#[must_use]
pub fn shape(mut self, shape: Shape) -> Self {
self.shape = shape;
self
}
#[must_use]
pub fn ink(mut self, ink: Ink) -> Self {
self.ink = ink;
self
}
#[must_use]
pub fn blink(mut self, blink: Blink) -> Self {
self.blink = blink;
self
}
pub fn render(self, area: Rect, buf: &mut Buffer, pos: Position, now: Instant) -> Report {
let position = clamp(area, pos);
if area.is_empty() || !area.contains(pos) {
return Report {
position,
redraw_after: None,
};
}
let (visible, redraw_after) = self.blink.phase(now);
if !visible {
return Report {
position,
redraw_after,
};
}
let width = buf
.cell(position)
.map_or(1, |c| UnicodeWidthStr::width(c.symbol()).max(1));
for dx in 0..u16::try_from(width).unwrap_or(1) {
let p = Position::new(position.x + dx, position.y);
if !area.contains(p) {
break;
}
self.paint(buf, p);
}
Report {
position,
redraw_after,
}
}
fn paint(self, buf: &mut Buffer, p: Position) {
let Some(cell) = buf.cell_mut(p) else { return };
match self.shape {
Shape::Block => {
let ink = match self.ink {
Ink::Auto => contrast_ink(self.color),
Ink::Fixed(c) => c,
};
cell.set_bg(self.color).set_fg(ink);
}
Shape::Bar => {
cell.set_symbol("\u{258f}").set_fg(self.color);
}
Shape::Underline => {
use ratatui::style::{Modifier, Style};
cell.set_style(
Style::default()
.add_modifier(Modifier::UNDERLINED)
.underline_color(self.color),
);
}
}
}
}
fn clamp(area: Rect, pos: Position) -> Position {
if area.is_empty() {
return Position::new(area.x, area.y);
}
Position::new(
pos.x.clamp(area.x, area.right() - 1),
pos.y.clamp(area.y, area.bottom() - 1),
)
}
#[cfg(test)]
mod tests {
use super::*;
const GREEN: Color = Color::Rgb(0x87, 0xaf, 0x5f);
fn area() -> Rect {
Rect::new(0, 0, 6, 1)
}
#[test]
fn block_sets_the_background_and_keeps_the_glyph() {
let mut buf = Buffer::with_lines(vec!["abcdef"]);
let report =
Cursor::new(GREEN).render(area(), &mut buf, Position::new(2, 0), Instant::now());
let cell = buf.cell(Position::new(2, 0)).unwrap();
assert_eq!(cell.symbol(), "c");
assert_eq!(cell.bg, GREEN);
assert_eq!(report.position, Position::new(2, 0));
assert_eq!(report.redraw_after, None);
}
#[test]
fn block_flips_a_same_coloured_glyph_to_readable_ink() {
let mut buf = Buffer::with_lines(vec!["abcdef"]);
buf.cell_mut(Position::new(2, 0)).unwrap().set_fg(GREEN);
let _ = Cursor::new(GREEN).render(area(), &mut buf, Position::new(2, 0), Instant::now());
let cell = buf.cell(Position::new(2, 0)).unwrap();
assert_ne!(cell.fg, GREEN, "the glyph would be invisible");
assert_eq!(cell.fg, Color::Rgb(0x11, 0x11, 0x11));
}
#[test]
fn fixed_ink_overrides_the_contrast_pick() {
let mut buf = Buffer::with_lines(vec!["abcdef"]);
let _ = Cursor::new(GREEN)
.ink(Ink::Fixed(Color::Rgb(1, 2, 3)))
.render(area(), &mut buf, Position::new(0, 0), Instant::now());
assert_eq!(
buf.cell(Position::new(0, 0)).unwrap().fg,
Color::Rgb(1, 2, 3)
);
}
#[test]
fn a_wide_glyph_gets_both_of_its_cells() {
let mut buf = Buffer::with_lines(vec!["\u{4f60}abcd"]);
let _ = Cursor::new(GREEN).render(area(), &mut buf, Position::new(0, 0), Instant::now());
assert_eq!(buf.cell(Position::new(0, 0)).unwrap().bg, GREEN);
assert_eq!(
buf.cell(Position::new(1, 0)).unwrap().bg,
GREEN,
"spacer cell left behind"
);
}
#[test]
fn a_position_outside_the_area_paints_nothing_but_still_reports() {
let mut buf = Buffer::with_lines(vec!["abcdef"]);
let report =
Cursor::new(GREEN).render(area(), &mut buf, Position::new(99, 0), Instant::now());
assert_eq!(buf.cell(Position::new(5, 0)).unwrap().bg, Color::Reset);
assert_eq!(
report.position,
Position::new(5, 0),
"clamped into the area"
);
}
#[test]
fn underline_preserves_the_cell_and_colours_the_line() {
use ratatui::style::Modifier;
let mut buf = Buffer::with_lines(vec!["abcdef"]);
buf.cell_mut(Position::new(2, 0))
.unwrap()
.set_fg(Color::Blue);
let _ = Cursor::new(GREEN).shape(Shape::Underline).render(
area(),
&mut buf,
Position::new(2, 0),
Instant::now(),
);
let cell = buf.cell(Position::new(2, 0)).unwrap();
assert_eq!(cell.symbol(), "c", "glyph must survive");
assert_eq!(cell.fg, Color::Blue, "foreground must survive");
assert_eq!(cell.bg, Color::Reset, "background must survive");
assert!(cell.modifier.contains(Modifier::UNDERLINED));
assert_eq!(cell.underline_color, GREEN);
}
#[test]
fn blink_paints_in_the_on_phase_and_skips_the_off_phase() {
let since = Instant::now();
let period = Duration::from_millis(500);
let mut on = Buffer::with_lines(vec!["abcdef"]);
let _ = Cursor::new(GREEN)
.blink(Blink::On { period, since })
.render(
area(),
&mut on,
Position::new(0, 0),
since + Duration::from_millis(100),
);
assert_eq!(
on.cell(Position::new(0, 0)).unwrap().bg,
GREEN,
"first half is on"
);
let mut off = Buffer::with_lines(vec!["abcdef"]);
let _ = Cursor::new(GREEN)
.blink(Blink::On { period, since })
.render(
area(),
&mut off,
Position::new(0, 0),
since + Duration::from_millis(600),
);
assert_eq!(
off.cell(Position::new(0, 0)).unwrap().bg,
Color::Reset,
"second half is off"
);
}
#[test]
fn redraw_after_counts_down_to_the_next_phase_boundary() {
let since = Instant::now();
let period = Duration::from_millis(500);
let mut buf = Buffer::with_lines(vec!["abcdef"]);
let report = Cursor::new(GREEN)
.blink(Blink::On { period, since })
.render(
area(),
&mut buf,
Position::new(0, 0),
since + Duration::from_millis(400),
);
assert_eq!(report.redraw_after, Some(Duration::from_millis(100)));
}
#[test]
fn a_steady_cursor_asks_for_no_redraw() {
let mut buf = Buffer::with_lines(vec!["abcdef"]);
let report =
Cursor::new(GREEN).render(area(), &mut buf, Position::new(0, 0), Instant::now());
assert_eq!(report.redraw_after, None);
}
#[test]
fn bar_replaces_the_glyph_which_is_documented_and_lossy() {
let mut buf = Buffer::with_lines(vec!["abcdef"]);
let _ = Cursor::new(GREEN).shape(Shape::Bar).render(
area(),
&mut buf,
Position::new(2, 0),
Instant::now(),
);
let cell = buf.cell(Position::new(2, 0)).unwrap();
assert_eq!(cell.symbol(), "\u{258f}");
assert_eq!(cell.fg, GREEN);
assert_eq!(cell.bg, Color::Reset, "the bar tints ink, not ground");
}
}