use crate::core::{Color, Style};
use std::fmt::Write as FmtWrite;
use unicode_width::UnicodeWidthChar;
#[derive(Debug, Clone, Default)]
pub struct StyledChar {
pub ch: char,
pub fg: Option<Color>,
pub bg: Option<Color>,
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub strikethrough: bool,
pub dim: bool,
pub inverse: bool,
}
impl StyledChar {
pub fn new(ch: char) -> Self {
Self {
ch,
..Default::default()
}
}
pub fn with_style(ch: char, style: &Style) -> Self {
Self {
ch,
fg: style.color,
bg: style.background_color,
bold: style.bold,
italic: style.italic,
underline: style.underline,
strikethrough: style.strikethrough,
dim: style.dim,
inverse: style.inverse,
}
}
pub fn has_style(&self) -> bool {
self.fg.is_some()
|| self.bg.is_some()
|| self.bold
|| self.italic
|| self.underline
|| self.strikethrough
|| self.dim
|| self.inverse
}
pub fn same_style(&self, other: &Self) -> bool {
self.fg == other.fg
&& self.bg == other.bg
&& self.bold == other.bold
&& self.italic == other.italic
&& self.underline == other.underline
&& self.strikethrough == other.strikethrough
&& self.dim == other.dim
&& self.inverse == other.inverse
}
}
#[derive(Debug, Clone)]
pub struct ClipRegion {
pub x1: u16,
pub y1: u16,
pub x2: u16,
pub y2: u16,
}
impl ClipRegion {
pub fn contains(&self, x: u16, y: u16) -> bool {
x >= self.x1 && x < self.x2 && y >= self.y1 && y < self.y2
}
}
pub struct Output {
pub width: u16,
pub height: u16,
grid: Vec<StyledChar>,
clip_stack: Vec<ClipRegion>,
dirty_rows: Vec<bool>,
any_dirty: bool,
}
impl Output {
pub fn new(width: u16, height: u16) -> Self {
let size = (width as usize) * (height as usize);
let grid = vec![StyledChar::new(' '); size];
Self {
width,
height,
grid,
clip_stack: Vec::new(),
dirty_rows: vec![false; height as usize],
any_dirty: false,
}
}
#[cfg(test)]
#[inline]
fn get(&self, col: usize, row: usize) -> Option<&StyledChar> {
if col < self.width as usize && row < self.height as usize {
let width = self.width as usize;
Some(&self.grid[(row * width) + col])
} else {
None
}
}
fn row_iter(&self, row: usize) -> impl Iterator<Item = &StyledChar> {
let start = row * (self.width as usize);
let end = start + (self.width as usize);
self.grid[start..end].iter()
}
#[cfg(test)]
pub fn cell_at(&self, col: usize, row: usize) -> Option<&StyledChar> {
self.get(col, row)
}
pub fn is_dirty(&self) -> bool {
self.any_dirty
}
pub fn is_row_dirty(&self, row: usize) -> bool {
self.dirty_rows.get(row).copied().unwrap_or(false)
}
pub fn clear_dirty(&mut self) {
self.dirty_rows.fill(false);
self.any_dirty = false;
}
pub fn dirty_row_indices(&self) -> impl Iterator<Item = usize> + '_ {
self.dirty_rows
.iter()
.enumerate()
.filter_map(|(i, &dirty)| if dirty { Some(i) } else { None })
}
pub fn render_dirty_rows(&self) -> Vec<(usize, String)> {
self.assert_no_active_clips("render_dirty_rows");
self.dirty_row_indices()
.map(|row_idx| {
let line = self.render_row(row_idx);
(row_idx, line)
})
.collect()
}
fn render_row(&self, row_idx: usize) -> String {
if row_idx >= self.height as usize {
return String::new();
}
let mut last_content_idx = 0;
for (i, cell) in self.row_iter(row_idx).enumerate() {
if cell.ch != '\0' && (cell.ch != ' ' || cell.has_style()) {
last_content_idx = i + 1;
}
}
let mut line = String::new();
let mut current_style: Option<StyledChar> = None;
for (i, cell) in self.row_iter(row_idx).enumerate() {
if i >= last_content_idx {
break;
}
if cell.ch == '\0' {
continue;
}
let need_style_change = match ¤t_style {
None => cell.has_style(),
Some(prev) => !cell.same_style(prev),
};
if need_style_change {
if current_style.is_some() {
line.push_str("\x1b[0m");
}
self.apply_style(&mut line, cell);
current_style = Some(cell.clone());
}
line.push(cell.ch);
}
if current_style.is_some() {
line.push_str("\x1b[0m");
}
line
}
#[inline]
fn mark_dirty(&mut self, row: usize) {
if row < self.dirty_rows.len() {
self.dirty_rows[row] = true;
self.any_dirty = true;
}
}
pub fn write(&mut self, x: u16, y: u16, text: &str, style: &Style) {
let mut col = x as usize;
let row = y as usize;
if row >= self.height as usize {
return;
}
self.mark_dirty(row);
let width = self.width as usize;
let clip_region = self.clip_stack.last().cloned();
if text.is_ascii() && clip_region.is_none() {
for byte in text.bytes() {
if byte == b'\n' || col >= width {
break;
}
self.write_char_at(col, row, byte as char, 1, style);
col += 1;
}
return;
}
if clip_region.is_none() {
for ch in text.chars() {
if ch == '\n' || col >= width {
break;
}
let char_width = ch.width().unwrap_or(1);
self.write_char_at(col, row, ch, char_width, style);
col += char_width;
}
return;
}
for ch in text.chars() {
if ch == '\n' || col >= width {
break;
}
let char_width = ch.width().unwrap_or(1);
if let Some(clip) = clip_region.as_ref()
&& !clip.contains(col as u16, row as u16)
{
col += char_width;
continue;
}
self.write_char_at(col, row, ch, char_width, style);
col += char_width;
}
}
pub fn write_char(&mut self, x: u16, y: u16, ch: char, style: &Style) {
let col = x as usize;
let row = y as usize;
if row >= self.height as usize || col >= self.width as usize {
return;
}
self.mark_dirty(row);
if let Some(clip) = self.clip_stack.last()
&& !clip.contains(x, y)
{
return;
}
let char_width = ch.width().unwrap_or(1);
self.write_char_at(col, row, ch, char_width, style);
}
fn write_char_at(
&mut self,
col: usize,
row: usize,
ch: char,
char_width: usize,
style: &Style,
) {
let width = self.width as usize;
let row_start = row * width;
let idx = row_start + col;
if char_width == 2 && col + 1 >= width {
self.grid[idx] = StyledChar::with_style(' ', style);
return;
}
if self.grid[idx].ch == '\0' && col > 0 {
self.grid[idx - 1] = StyledChar::new(' ');
}
let old_char_width = self.grid[idx].ch.width().unwrap_or(1);
if old_char_width == 2 && col + 1 < width {
self.grid[idx + 1] = StyledChar::new(' ');
}
self.grid[idx] = StyledChar::with_style(ch, style);
if char_width == 2 && col + 1 < width {
let next_idx = idx + 1;
if self.grid[next_idx].ch != '\0' {
let next_char_width = self.grid[next_idx].ch.width().unwrap_or(1);
if next_char_width == 2 && col + 2 < width {
self.grid[idx + 2] = StyledChar::new(' ');
}
}
self.grid[next_idx] = StyledChar::new('\0');
}
}
pub fn fill_rect(&mut self, x: u16, y: u16, width: u16, height: u16, ch: char, style: &Style) {
for row in y..(y + height).min(self.height) {
for col in x..(x + width).min(self.width) {
self.write_char(col, row, ch, style);
}
}
}
pub fn clip(&mut self, region: ClipRegion) {
assert!(
region.x1 <= region.x2 && region.y1 <= region.y2,
"Invalid clip region: min > max"
);
self.clip_stack.push(region);
}
pub fn unclip(&mut self) {
assert!(
self.clip_stack.pop().is_some(),
"Output::unclip called with an empty clip stack"
);
}
pub(crate) fn clip_depth(&self) -> usize {
self.clip_stack.len()
}
fn assert_no_active_clips(&self, method: &str) {
debug_assert!(
self.clip_stack.is_empty(),
"Output::{} called with an unbalanced clip stack (depth={})",
method,
self.clip_stack.len()
);
}
pub fn render(&self) -> String {
self.assert_no_active_clips("render");
let mut lines: Vec<String> = if self.is_dirty() {
let dirty_rows = self.render_dirty_rows();
if dirty_rows.is_empty() {
Vec::new()
} else {
let mut sparse =
vec![String::new(); dirty_rows.last().map(|(row, _)| row + 1).unwrap_or(0)];
for (row, line) in dirty_rows {
sparse[row] = line;
}
sparse
}
} else {
(0..self.height as usize)
.map(|row_idx| self.render_row(row_idx))
.collect()
};
while lines.last().map(|l| l.is_empty()).unwrap_or(false) {
lines.pop();
}
lines.join("\r\n")
}
pub fn render_fixed_height(&self) -> String {
self.assert_no_active_clips("render_fixed_height");
let lines: Vec<String> = (0..self.height as usize)
.map(|row_idx| self.render_row(row_idx))
.collect();
lines.join("\r\n")
}
fn apply_style(&self, result: &mut String, cell: &StyledChar) {
let mut codes: Vec<u8> = Vec::new();
if cell.bold {
codes.push(1);
}
if cell.dim {
codes.push(2);
}
if cell.italic {
codes.push(3);
}
if cell.underline {
codes.push(4);
}
if cell.inverse {
codes.push(7);
}
if cell.strikethrough {
codes.push(9);
}
if let Some(fg) = cell.fg {
self.color_to_ansi(fg, false, &mut codes);
}
if let Some(bg) = cell.bg {
self.color_to_ansi(bg, true, &mut codes);
}
if !codes.is_empty() {
result.push_str("\x1b[");
for (i, code) in codes.iter().enumerate() {
if i > 0 {
result.push(';');
}
let _ = write!(result, "{}", code);
}
result.push('m');
}
}
fn color_to_ansi(&self, color: Color, background: bool, codes: &mut Vec<u8>) {
color.push_ansi_codes(background, codes);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_output_creation() {
let output = Output::new(80, 24);
assert_eq!(output.width, 80);
assert_eq!(output.height, 24);
}
#[test]
fn test_write_text() {
let mut output = Output::new(80, 24);
output.write(0, 0, "Hello", &Style::default());
assert_eq!(output.cell_at(0, 0).unwrap().ch, 'H');
assert_eq!(output.cell_at(4, 0).unwrap().ch, 'o');
}
#[test]
fn test_styled_output() {
let mut output = Output::new(80, 24);
let style = Style {
color: Some(Color::Green),
bold: true,
..Style::default()
};
output.write(0, 0, "Test", &style);
let rendered = output.render();
assert!(rendered.contains("\x1b["));
}
#[test]
fn test_wide_char_placeholder() {
let mut output = Output::new(80, 24);
output.write(0, 0, "ä½ å¥½", &Style::default());
assert_eq!(output.cell_at(0, 0).unwrap().ch, 'ä½ ');
assert_eq!(output.cell_at(1, 0).unwrap().ch, '\0');
assert_eq!(output.cell_at(2, 0).unwrap().ch, '好');
assert_eq!(output.cell_at(3, 0).unwrap().ch, '\0');
}
#[test]
fn test_overwrite_wide_char_placeholder() {
let mut output = Output::new(80, 24);
output.write(0, 0, "ä½ ", &Style::default());
assert_eq!(output.cell_at(0, 0).unwrap().ch, 'ä½ ');
assert_eq!(output.cell_at(1, 0).unwrap().ch, '\0');
output.write_char(1, 0, 'X', &Style::default());
assert_eq!(output.cell_at(0, 0).unwrap().ch, ' ');
assert_eq!(output.cell_at(1, 0).unwrap().ch, 'X');
}
#[test]
fn test_write_overwrite_wide_char_placeholder() {
let mut output = Output::new(80, 24);
output.write(0, 0, "ä½ ", &Style::default());
output.write(1, 0, "XY", &Style::default());
assert_eq!(output.cell_at(0, 0).unwrap().ch, ' ');
assert_eq!(output.cell_at(1, 0).unwrap().ch, 'X');
assert_eq!(output.cell_at(2, 0).unwrap().ch, 'Y');
}
#[test]
fn test_overwrite_wide_char_first_half() {
let mut output = Output::new(80, 24);
output.write(0, 0, "ä½ ", &Style::default());
assert_eq!(output.cell_at(0, 0).unwrap().ch, 'ä½ ');
assert_eq!(output.cell_at(1, 0).unwrap().ch, '\0');
output.write_char(0, 0, 'X', &Style::default());
assert_eq!(output.cell_at(0, 0).unwrap().ch, 'X');
assert_eq!(output.cell_at(1, 0).unwrap().ch, ' ');
}
#[test]
fn test_wide_char_render_no_duplicate() {
let mut output = Output::new(80, 24);
output.write(0, 0, "ä½ å¥½ä¸–ç•Œ", &Style::default());
let rendered = output.render();
assert_eq!(rendered, "ä½ å¥½ä¸–ç•Œ");
}
#[test]
fn test_raw_mode_line_endings() {
let mut output = Output::new(40, 5);
output.write(0, 0, "Line 1", &Style::default());
output.write(0, 1, "Line 2", &Style::default());
output.write(0, 2, "Line 3", &Style::default());
let rendered = output.render();
assert!(
rendered.contains("\r\n"),
"Output must use CRLF line endings for raw mode"
);
let lines: Vec<&str> = rendered.split("\r\n").collect();
assert!(lines.len() >= 3, "Should have at least 3 lines");
for line in &lines {
assert!(
!line.contains('\n'),
"Should not have standalone LF within lines"
);
}
}
#[test]
fn test_line_alignment_in_output() {
let mut output = Output::new(20, 3);
output.write(0, 0, "AAAA", &Style::default());
output.write(0, 1, "BBBB", &Style::default());
output.write(0, 2, "CCCC", &Style::default());
let rendered = output.render();
let lines: Vec<&str> = rendered.split("\r\n").collect();
assert_eq!(lines[0], "AAAA");
assert_eq!(lines[1], "BBBB");
assert_eq!(lines[2], "CCCC");
}
#[test]
fn test_wide_char_at_boundary() {
let mut output = Output::new(5, 1);
output.write(3, 0, "ä½ ", &Style::default());
assert_eq!(output.cell_at(3, 0).unwrap().ch, 'ä½ ');
assert_eq!(output.cell_at(4, 0).unwrap().ch, '\0');
let mut output2 = Output::new(5, 1);
output2.write(4, 0, "ä½ ", &Style::default());
assert_eq!(output2.cell_at(4, 0).unwrap().ch, ' ');
}
#[test]
fn test_wide_char_at_exact_boundary() {
let mut output = Output::new(4, 1);
output.write(2, 0, "ä½ ", &Style::default());
assert_eq!(output.cell_at(2, 0).unwrap().ch, 'ä½ ');
assert_eq!(output.cell_at(3, 0).unwrap().ch, '\0');
}
#[test]
fn test_dirty_tracking_initial_state() {
let output = Output::new(80, 24);
assert!(!output.is_dirty());
assert!(!output.is_row_dirty(0));
}
#[test]
fn test_dirty_tracking_after_write() {
let mut output = Output::new(80, 24);
output.write(0, 5, "Hello", &Style::default());
assert!(output.is_dirty());
assert!(output.is_row_dirty(5));
assert!(!output.is_row_dirty(0));
assert!(!output.is_row_dirty(6));
}
#[test]
fn test_dirty_tracking_after_write_char() {
let mut output = Output::new(80, 24);
output.write_char(10, 3, 'X', &Style::default());
assert!(output.is_dirty());
assert!(output.is_row_dirty(3));
assert!(!output.is_row_dirty(2));
}
#[test]
fn test_dirty_tracking_clear() {
let mut output = Output::new(80, 24);
output.write(0, 0, "Test", &Style::default());
output.write(0, 5, "Test", &Style::default());
assert!(output.is_dirty());
assert!(output.is_row_dirty(0));
assert!(output.is_row_dirty(5));
output.clear_dirty();
assert!(!output.is_dirty());
assert!(!output.is_row_dirty(0));
assert!(!output.is_row_dirty(5));
}
#[test]
fn test_dirty_row_indices() {
let mut output = Output::new(80, 24);
output.write(0, 1, "A", &Style::default());
output.write(0, 3, "B", &Style::default());
output.write(0, 7, "C", &Style::default());
let dirty: Vec<usize> = output.dirty_row_indices().collect();
assert_eq!(dirty, vec![1, 3, 7]);
}
#[test]
fn test_render_dirty_rows() {
let mut output = Output::new(80, 24);
output.write(0, 0, "Line 0", &Style::default());
output.write(0, 2, "Line 2", &Style::default());
let dirty_rows = output.render_dirty_rows();
assert_eq!(dirty_rows.len(), 2);
assert_eq!(dirty_rows[0].0, 0);
assert_eq!(dirty_rows[0].1, "Line 0");
assert_eq!(dirty_rows[1].0, 2);
assert_eq!(dirty_rows[1].1, "Line 2");
}
#[test]
fn test_render_after_clear_dirty_preserves_content() {
let mut output = Output::new(10, 2);
output.write(0, 0, "A", &Style::default());
output.clear_dirty();
assert_eq!(output.render(), "A");
}
#[test]
fn test_render_sparse_dirty_rows_preserves_line_gaps() {
let mut output = Output::new(10, 4);
output.write(0, 2, "C", &Style::default());
assert_eq!(output.render(), "\r\n\r\nC");
}
#[test]
#[cfg(debug_assertions)]
#[should_panic(expected = "Output::unclip called with an empty clip stack")]
fn test_unclip_panics_when_stack_is_empty_in_debug() {
let mut output = Output::new(10, 5);
output.unclip();
}
#[test]
fn test_clip_depth_tracks_push_and_pop() {
let mut output = Output::new(10, 5);
assert_eq!(output.clip_depth(), 0);
output.clip(ClipRegion {
x1: 0,
y1: 0,
x2: 5,
y2: 5,
});
assert_eq!(output.clip_depth(), 1);
output.unclip();
assert_eq!(output.clip_depth(), 0);
}
#[test]
#[should_panic(expected = "Output::render called with an unbalanced clip stack")]
fn test_render_panics_with_active_clip_stack() {
let mut output = Output::new(10, 5);
output.clip(ClipRegion {
x1: 0,
y1: 0,
x2: 5,
y2: 5,
});
let _ = output.render();
}
}