use ratatui_core::style::{Color, Modifier, Style};
use crate::frame::Diff;
const BSU: &[u8] = b"\x1b[?2026h";
const ESU: &[u8] = b"\x1b[?2026l";
const HIDE_CURSOR: &[u8] = b"\x1b[?25l";
#[allow(dead_code)]
const SHOW_CURSOR: &[u8] = b"\x1b[?25h";
#[derive(Debug, Clone)]
pub struct CursorState {
pub row: u16,
pub col: u16,
pub style: Style,
}
impl CursorState {
pub fn new() -> Self {
Self {
row: 0,
col: 0,
style: Style::default(),
}
}
}
impl Default for CursorState {
fn default() -> Self {
Self::new()
}
}
impl Diff {
pub fn to_escape_sequences(&self, cursor: &mut CursorState) -> Vec<u8> {
if self.cells.is_empty() {
return Vec::new();
}
let mut out = Vec::with_capacity(self.cells.len() * 12);
out.extend_from_slice(BSU);
out.extend_from_slice(HIDE_CURSOR);
for (x, y, cell) in &self.cells {
let target_row = *y;
let target_col = *x;
let need_move = cursor.row != target_row || cursor.col != target_col;
if need_move {
write_relative_move(&mut out, cursor, target_row, target_col);
}
write_style_diff(&mut out, &cursor.style, &cell.style());
cursor.style = cell.style();
let symbol = cell.symbol();
out.extend_from_slice(symbol.as_bytes());
let width = unicode_display_width(symbol);
cursor.col = cursor.col.saturating_add(width as u16);
}
out.extend_from_slice(b"\x1b[0m");
cursor.style = Style::default();
out.extend_from_slice(ESU);
out
}
}
pub fn write_relative_move(
out: &mut Vec<u8>,
cursor: &mut CursorState,
target_row: u16,
target_col: u16,
) {
if target_row < cursor.row {
let n = cursor.row - target_row;
if n == 1 {
out.extend_from_slice(b"\x1b[A");
} else {
out.extend_from_slice(format!("\x1b[{}A", n).as_bytes());
}
} else if target_row > cursor.row {
let n = target_row - cursor.row;
if n == 1 {
out.extend_from_slice(b"\x1b[B");
} else {
out.extend_from_slice(format!("\x1b[{}B", n).as_bytes());
}
}
cursor.row = target_row;
out.push(b'\r'); cursor.col = 0;
if target_col > 0 {
if target_col == 1 {
out.extend_from_slice(b"\x1b[C");
} else {
out.extend_from_slice(format!("\x1b[{}C", target_col).as_bytes());
}
}
cursor.col = target_col;
}
fn write_style_diff(out: &mut Vec<u8>, from: &Style, to: &Style) {
if from == to {
return;
}
let from_mods = from.add_modifier;
let to_mods = to.add_modifier;
let removed_mods = from_mods.difference(to_mods);
let needs_reset = !removed_mods.is_empty()
|| (from.fg.is_some() && to.fg.is_none())
|| (from.bg.is_some() && to.bg.is_none());
if needs_reset {
out.extend_from_slice(b"\x1b[0m");
write_full_style(out, to);
} else {
write_incremental_style(out, from, to);
}
}
fn write_full_style(out: &mut Vec<u8>, style: &Style) {
let mut params: Vec<u8> = Vec::new();
let mut first = true;
macro_rules! push_param {
($val:expr) => {
if !first {
params.push(b';');
}
params.extend_from_slice($val.to_string().as_bytes());
first = false;
};
}
let mods = style.add_modifier;
if mods.contains(Modifier::BOLD) {
push_param!(1);
}
if mods.contains(Modifier::DIM) {
push_param!(2);
}
if mods.contains(Modifier::ITALIC) {
push_param!(3);
}
if mods.contains(Modifier::UNDERLINED) {
push_param!(4);
}
if mods.contains(Modifier::SLOW_BLINK) {
push_param!(5);
}
if mods.contains(Modifier::RAPID_BLINK) {
push_param!(6);
}
if mods.contains(Modifier::REVERSED) {
push_param!(7);
}
if mods.contains(Modifier::HIDDEN) {
push_param!(8);
}
if mods.contains(Modifier::CROSSED_OUT) {
push_param!(9);
}
if let Some(fg) = style.fg {
write_color_params(&mut params, fg, true, &mut first);
}
if let Some(bg) = style.bg {
write_color_params(&mut params, bg, false, &mut first);
}
if !params.is_empty() {
out.extend_from_slice(b"\x1b[");
out.extend_from_slice(¶ms);
out.push(b'm');
}
}
fn write_incremental_style(out: &mut Vec<u8>, from: &Style, to: &Style) {
let mut params: Vec<u8> = Vec::new();
let mut first = true;
macro_rules! push_param {
($val:expr) => {
if !first {
params.push(b';');
}
params.extend_from_slice($val.to_string().as_bytes());
first = false;
};
}
let added_mods = to.add_modifier.difference(from.add_modifier);
if added_mods.contains(Modifier::BOLD) {
push_param!(1);
}
if added_mods.contains(Modifier::DIM) {
push_param!(2);
}
if added_mods.contains(Modifier::ITALIC) {
push_param!(3);
}
if added_mods.contains(Modifier::UNDERLINED) {
push_param!(4);
}
if added_mods.contains(Modifier::SLOW_BLINK) {
push_param!(5);
}
if added_mods.contains(Modifier::RAPID_BLINK) {
push_param!(6);
}
if added_mods.contains(Modifier::REVERSED) {
push_param!(7);
}
if added_mods.contains(Modifier::HIDDEN) {
push_param!(8);
}
if added_mods.contains(Modifier::CROSSED_OUT) {
push_param!(9);
}
if from.fg != to.fg
&& let Some(fg) = to.fg
{
write_color_params(&mut params, fg, true, &mut first);
}
if from.bg != to.bg
&& let Some(bg) = to.bg
{
write_color_params(&mut params, bg, false, &mut first);
}
if !params.is_empty() {
out.extend_from_slice(b"\x1b[");
out.extend_from_slice(¶ms);
out.push(b'm');
}
}
fn push_param(params: &mut Vec<u8>, first: &mut bool, val: u16) {
if !*first {
params.push(b';');
}
params.extend_from_slice(val.to_string().as_bytes());
*first = false;
}
fn write_color_params(params: &mut Vec<u8>, color: Color, is_fg: bool, first: &mut bool) {
let code = |fg: u16, bg: u16| -> u16 { if is_fg { fg } else { bg } };
match color {
Color::Reset => push_param(params, first, code(39, 49)),
Color::Black => push_param(params, first, code(30, 40)),
Color::Red => push_param(params, first, code(31, 41)),
Color::Green => push_param(params, first, code(32, 42)),
Color::Yellow => push_param(params, first, code(33, 43)),
Color::Blue => push_param(params, first, code(34, 44)),
Color::Magenta => push_param(params, first, code(35, 45)),
Color::Cyan => push_param(params, first, code(36, 46)),
Color::Gray => push_param(params, first, code(37, 47)),
Color::DarkGray => push_param(params, first, code(90, 100)),
Color::LightRed => push_param(params, first, code(91, 101)),
Color::LightGreen => push_param(params, first, code(92, 102)),
Color::LightYellow => push_param(params, first, code(93, 103)),
Color::LightBlue => push_param(params, first, code(94, 104)),
Color::LightMagenta => push_param(params, first, code(95, 105)),
Color::LightCyan => push_param(params, first, code(96, 106)),
Color::White => push_param(params, first, code(97, 107)),
Color::Indexed(i) => {
push_param(params, first, code(38, 48));
push_param(params, first, 5);
push_param(params, first, i as u16);
}
Color::Rgb(r, g, b) => {
push_param(params, first, code(38, 48));
push_param(params, first, 2);
push_param(params, first, r as u16);
push_param(params, first, g as u16);
push_param(params, first, b as u16);
}
}
}
fn unicode_display_width(s: &str) -> usize {
unicode_width::UnicodeWidthStr::width(s)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::frame::{Diff, Frame};
use ratatui_core::{
buffer::Buffer,
layout::Rect,
style::{Color, Style},
};
fn make_frame(lines: &[&str]) -> Frame {
Frame::new(Buffer::with_lines(lines.iter().map(|s| s.to_string())))
}
#[test]
fn empty_diff_produces_no_output() {
let diff = Diff {
cells: vec![],
new_area: Rect::new(0, 0, 5, 1),
prev_area: Rect::new(0, 0, 5, 1),
};
let mut cursor = CursorState::new();
let output = diff.to_escape_sequences(&mut cursor);
assert!(output.is_empty());
}
#[test]
fn single_cell_produces_sync_wrapped_output() {
let f1 = make_frame(&["hello"]);
let f2 = make_frame(&["hallo"]);
let diff = f2.diff(&f1);
let mut cursor = CursorState::new();
let output = diff.to_escape_sequences(&mut cursor);
let s = String::from_utf8_lossy(&output);
assert!(s.starts_with("\x1b[?2026h"));
assert!(s.ends_with("\x1b[?2026l"));
assert!(s.contains('a'));
}
#[test]
fn relative_movement_for_non_origin_cell() {
let f1 = make_frame(&["hello"]);
let f2 = make_frame(&["hallo"]);
let diff = f2.diff(&f1);
let mut cursor = CursorState::new();
let output = diff.to_escape_sequences(&mut cursor);
let s = String::from_utf8_lossy(&output);
assert!(s.contains('\r'));
assert!(s.contains("\x1b[C")); assert!(!s.contains("H"));
}
#[test]
fn consecutive_cells_advance_cursor() {
let f1 = make_frame(&["hello"]);
let f2 = make_frame(&["abllo"]);
let diff = f2.diff(&f1);
let mut cursor = CursorState::new();
let output = diff.to_escape_sequences(&mut cursor);
let s = String::from_utf8_lossy(&output);
assert!(s.contains('a'));
assert!(s.contains('b'));
}
#[test]
fn multirow_uses_relative_vertical_movement() {
let f1 = make_frame(&["hello", "world"]);
let f2 = make_frame(&["hello", "earth"]);
let diff = f2.diff(&f1);
let mut cursor = CursorState::new();
let output = diff.to_escape_sequences(&mut cursor);
let s = String::from_utf8_lossy(&output);
assert!(s.contains("\x1b[B"));
}
#[test]
fn style_change_emits_sgr() {
let area = Rect::new(0, 0, 5, 1);
let mut buf1 = Buffer::empty(area);
buf1.set_string(0, 0, "hello", Style::default());
let mut buf2 = Buffer::empty(area);
buf2.set_string(0, 0, "hello", Style::default().fg(Color::Red));
let f1 = Frame::new(buf1);
let f2 = Frame::new(buf2);
let diff = f2.diff(&f1);
let mut cursor = CursorState::new();
let output = diff.to_escape_sequences(&mut cursor);
let s = String::from_utf8_lossy(&output);
assert!(s.contains("31"));
}
}