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,
}
}
#[inline]
fn index(&self, col: usize, row: usize) -> usize {
row * (self.width as usize) + col
}
#[inline]
fn get(&self, col: usize, row: usize) -> Option<&StyledChar> {
if col < self.width as usize && row < self.height as usize {
Some(&self.grid[self.index(col, row)])
} else {
None
}
}
#[inline]
fn set(&mut self, col: usize, row: usize, value: StyledChar) {
if col < self.width as usize && row < self.height as usize {
let idx = self.index(col, row);
self.grid[idx] = value;
}
}
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;
for ch in text.chars() {
if ch == '\n' {
break;
}
if col >= width {
break;
}
let char_width = ch.width().unwrap_or(1);
if char_width == 2 && col + 1 >= width {
self.set(col, row, StyledChar::with_style(' ', style));
col += 1;
continue;
}
if let Some(clip) = self.clip_stack.last()
&& !clip.contains(col as u16, row as u16)
{
col += char_width;
continue;
}
if let Some(cell) = self.get(col, row) {
if cell.ch == '\0' && col > 0 {
self.set(col - 1, row, StyledChar::new(' '));
}
}
if let Some(cell) = self.get(col, row) {
let old_char_width = cell.ch.width().unwrap_or(1);
if old_char_width == 2 && col + 1 < width {
self.set(col + 1, row, StyledChar::new(' '));
}
}
self.set(col, row, StyledChar::with_style(ch, style));
if char_width == 2 && col + 1 < width {
if let Some(next_cell) = self.get(col + 1, row) {
if next_cell.ch != '\0' {
let next_char_width = next_cell.ch.width().unwrap_or(1);
if next_char_width == 2 && col + 2 < width {
self.set(col + 2, row, StyledChar::new(' '));
}
}
}
self.set(col + 1, row, StyledChar::new('\0'));
}
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;
let width = self.width as usize;
if row >= self.height as usize || col >= width {
return;
}
self.mark_dirty(row);
let char_width = ch.width().unwrap_or(1);
if char_width == 2 && col + 1 >= width {
self.set(col, row, StyledChar::with_style(' ', style));
return;
}
if let Some(clip) = self.clip_stack.last()
&& !clip.contains(x, y)
{
return;
}
if let Some(cell) = self.get(col, row) {
if cell.ch == '\0' && col > 0 {
self.set(col - 1, row, StyledChar::new(' '));
}
}
if let Some(cell) = self.get(col, row) {
let old_char_width = cell.ch.width().unwrap_or(1);
if old_char_width == 2 && col + 1 < width {
self.set(col + 1, row, StyledChar::new(' '));
}
}
self.set(col, row, StyledChar::with_style(ch, style));
if char_width == 2 && col + 1 < width {
if let Some(next_cell) = self.get(col + 1, row) {
let next_char_width = next_cell.ch.width().unwrap_or(1);
if next_char_width == 2 && col + 2 < width {
self.set(col + 2, row, StyledChar::new(' '));
}
}
self.set(col + 1, row, 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) {
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> = Vec::new();
for row_idx in 0..self.height as usize {
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");
}
lines.push(line);
}
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 mut lines: Vec<String> = Vec::new();
for row_idx in 0..self.height as usize {
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");
}
lines.push(line);
}
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>) {
let base = if background { 40 } else { 30 };
match color {
Color::Reset => {}
Color::Black => codes.push(base),
Color::Red => codes.push(base + 1),
Color::Green => codes.push(base + 2),
Color::Yellow => codes.push(base + 3),
Color::Blue => codes.push(base + 4),
Color::Magenta => codes.push(base + 5),
Color::Cyan => codes.push(base + 6),
Color::White => codes.push(base + 7),
Color::BrightBlack => codes.push(base + 60),
Color::BrightRed => codes.push(base + 61),
Color::BrightGreen => codes.push(base + 62),
Color::BrightYellow => codes.push(base + 63),
Color::BrightBlue => codes.push(base + 64),
Color::BrightMagenta => codes.push(base + 65),
Color::BrightCyan => codes.push(base + 66),
Color::BrightWhite => codes.push(base + 67),
Color::Ansi256(n) => {
codes.push(if background { 48 } else { 38 });
codes.push(5);
codes.push(n);
}
Color::Rgb(r, g, b) => {
codes.push(if background { 48 } else { 38 });
codes.push(2);
codes.push(r);
codes.push(g);
codes.push(b);
}
}
}
}
#[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 mut style = Style::default();
style.color = Some(Color::Green);
style.bold = true;
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_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]
#[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();
}
}