use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color as RColor, Modifier, Style};
use ratatui::widgets::Widget;
use super::layout::Layout;
use crate::ui::renderer::{LineEntry, SelectionRange};
#[derive(Clone, Copy)]
pub struct ChatPane<'a> {
layout: &'a Layout,
lines: &'a [LineEntry],
scroll_offset: usize,
border_style: Style,
selection: Option<SelectionRange>,
}
impl<'a> ChatPane<'a> {
pub fn new(layout: &'a Layout, lines: &'a [LineEntry], scroll_offset: usize) -> Self {
Self {
layout,
lines,
scroll_offset,
border_style: Style::default().fg(RColor::Green),
selection: None,
}
}
pub fn border_style(mut self, style: Style) -> Self {
self.border_style = style;
self
}
pub fn selection(mut self, sel: SelectionRange) -> Self {
self.selection = Some(sel);
self
}
}
impl<'a> Widget for ChatPane<'a> {
fn render(self, _area: Rect, buf: &mut Buffer) {
let l = self.layout;
let visible = l.chat.height as usize;
for dy in 0..l.chat.height {
let y = l.chat.y + dy;
if l.chat_v_left_col < buf.area.width {
buf[(l.chat_v_left_col, y)]
.set_char('│')
.set_style(self.border_style);
}
if l.chat_v_right_col < buf.area.width {
buf[(l.chat_v_right_col, y)]
.set_char('│')
.set_style(self.border_style);
}
}
if visible == 0 || l.chat.width == 0 || self.lines.is_empty() {
return;
}
let total = self.lines.len();
let end = total.saturating_sub(self.scroll_offset);
let start = end.saturating_sub(visible);
let slice = &self.lines[start..end];
let text_w = l.chat.width.saturating_sub(1);
for (i, entry) in slice.iter().enumerate() {
let y = l.chat.y + i as u16;
paint_line(buf, l.chat.x, y, text_w, entry);
if let Some(sel) = self.selection {
let line_idx = start + i;
apply_selection_to_row(buf, l.chat.x, y, text_w, entry, line_idx, &sel);
}
}
}
}
fn apply_selection_to_row(
buf: &mut Buffer,
x: u16,
y: u16,
width: u16,
entry: &LineEntry,
line_idx: usize,
sel: &SelectionRange,
) {
use unicode_width::UnicodeWidthChar;
if width == 0 {
return;
}
if line_idx < sel.start.0 || line_idx > sel.end.0 {
return;
}
let clean: Vec<char> = crate::ui::ansi::strip_ansi(&entry.text).chars().collect();
let line_char_len = clean.len();
let (lo, hi) = if line_idx == sel.start.0 && line_idx == sel.end.0 {
(sel.start.1.min(line_char_len), sel.end.1.min(line_char_len))
} else if line_idx == sel.start.0 {
(sel.start.1.min(line_char_len), line_char_len)
} else if line_idx == sel.end.0 {
(0, sel.end.1.min(line_char_len))
} else {
(0, line_char_len)
};
if lo >= hi {
return;
}
let col_start_off: u16 = clean[..lo]
.iter()
.map(|c| UnicodeWidthChar::width(*c).unwrap_or(0) as u16)
.sum();
let col_end_off: u16 = col_start_off
+ clean[lo..hi]
.iter()
.map(|c| UnicodeWidthChar::width(*c).unwrap_or(0) as u16)
.sum::<u16>();
let cell_x_lo = x.saturating_add(col_start_off);
let cell_x_hi = x.saturating_add(col_end_off).min(x.saturating_add(width));
if cell_x_lo >= cell_x_hi {
return;
}
for cx in cell_x_lo..cell_x_hi {
if cx >= buf.area.x.saturating_add(buf.area.width) {
break;
}
let cell = &mut buf[(cx, y)];
cell.modifier.insert(Modifier::REVERSED);
}
}
fn paint_line(buf: &mut Buffer, x: u16, y: u16, width: u16, entry: &LineEntry) {
use ansi_to_tui::IntoText;
use ratatui::layout::Rect;
use ratatui::widgets::Widget;
if width == 0 {
return;
}
let base_style = Style::default().fg(crossterm_to_ratatui(entry.color));
let text = crate::ui::ansi::strip_non_sgr_escapes(&entry.text);
match text.as_ref().into_text() {
Ok(parsed) => {
if let Some(line) = parsed.lines.into_iter().next() {
let line = line.style(base_style);
let area = Rect::new(x, y, width, 1);
line.render(area, buf);
}
}
Err(_) => {
buf.set_stringn(x, y, text.as_ref(), width as usize, base_style);
}
}
}
pub fn crossterm_to_ratatui(c: crossterm::style::Color) -> RColor {
use crossterm::style::Color as CC;
match c {
CC::Reset => RColor::Reset,
CC::Black => RColor::Black,
CC::DarkGrey => RColor::DarkGray,
CC::Red => RColor::LightRed,
CC::DarkRed => RColor::Red,
CC::Green => RColor::LightGreen,
CC::DarkGreen => RColor::Green,
CC::Yellow => RColor::LightYellow,
CC::DarkYellow => RColor::Yellow,
CC::Blue => RColor::LightBlue,
CC::DarkBlue => RColor::Blue,
CC::Magenta => RColor::LightMagenta,
CC::DarkMagenta => RColor::Magenta,
CC::Cyan => RColor::LightCyan,
CC::DarkCyan => RColor::Cyan,
CC::White => RColor::White,
CC::Grey => RColor::Gray,
CC::Rgb { r, g, b } => RColor::Rgb(r, g, b),
CC::AnsiValue(v) => RColor::Indexed(v),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::style::Color as CC;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn line(text: &str, color: CC) -> LineEntry {
LineEntry {
text: text.into(),
color,
}
}
#[test]
fn renders_borders_on_empty_buffer() {
let layout = Layout::new(160, 30, 1);
let mut backend = TestBackend::new(160, 30);
let mut terminal = Terminal::new(backend.clone()).unwrap();
let lines: Vec<LineEntry> = vec![];
terminal
.draw(|f| {
let area = f.area();
f.render_widget(ChatPane::new(&layout, &lines, 0), area);
})
.unwrap();
backend = terminal.backend().clone();
for dy in 0..layout.chat.height {
let y = layout.chat.y + dy;
assert_eq!(
backend
.buffer()
.cell((layout.chat_v_left_col, y))
.unwrap()
.symbol(),
"│",
"missing left │ at row {y}"
);
assert_eq!(
backend
.buffer()
.cell((layout.chat_v_right_col, y))
.unwrap()
.symbol(),
"│",
"missing right │ at row {y}"
);
}
}
#[test]
fn paints_buffer_lines_into_chat_rect() {
let layout = Layout::new(160, 30, 1);
let mut backend = TestBackend::new(160, 30);
let mut terminal = Terminal::new(backend.clone()).unwrap();
let lines = vec![line("hello", CC::Green), line("world", CC::Cyan)];
terminal
.draw(|f| {
let area = f.area();
f.render_widget(ChatPane::new(&layout, &lines, 0), area);
})
.unwrap();
backend = terminal.backend().clone();
let row_hello = layout.chat.y;
let row_world = row_hello + 1;
let read = |y: u16, w: u16| -> String {
(0..w)
.map(|i| {
backend
.buffer()
.cell((layout.chat.x + i, y))
.unwrap()
.symbol()
.to_string()
})
.collect()
};
assert_eq!(read(row_hello, 5), "hello");
assert_eq!(read(row_world, 5), "world");
}
#[test]
fn long_line_clips_at_chat_width() {
let layout = Layout::new(40, 10, 1);
let mut backend = TestBackend::new(40, 10);
let mut terminal = Terminal::new(backend.clone()).unwrap();
let long = "x".repeat(200);
let lines = vec![line(&long, CC::Green)];
terminal
.draw(|f| {
let area = f.area();
f.render_widget(ChatPane::new(&layout, &lines, 0), area);
})
.unwrap();
backend = terminal.backend().clone();
let row = layout.chat.y;
let text_w = layout.chat.width - 1;
for i in 0..text_w {
assert_eq!(
backend
.buffer()
.cell((layout.chat.x + i, row))
.unwrap()
.symbol(),
"x",
"expected 'x' at col {} (chat content)",
layout.chat.x + i
);
}
assert_eq!(
backend
.buffer()
.cell((layout.chat.x + text_w, row))
.unwrap()
.symbol(),
" ",
"expected the 1-cell right margin to be blank"
);
assert_eq!(
backend
.buffer()
.cell((layout.chat_v_right_col, row))
.unwrap()
.symbol(),
"│"
);
}
#[test]
fn scroll_offset_windows_older_lines() {
let layout = Layout::new(160, 30, 1); let mut backend = TestBackend::new(160, 30);
let mut terminal = Terminal::new(backend.clone()).unwrap();
let lines: Vec<LineEntry> = (0..30).map(|i| line(&format!("L{i}"), CC::Green)).collect();
terminal
.draw(|f| {
let area = f.area();
f.render_widget(ChatPane::new(&layout, &lines, 5), area);
})
.unwrap();
backend = terminal.backend().clone();
let row_top = layout.chat.y;
let row_bot = layout.chat.y + layout.chat.height - 1;
let read = |y: u16, w: u16| -> String {
(0..w)
.map(|i| {
backend
.buffer()
.cell((layout.chat.x + i, y))
.unwrap()
.symbol()
.to_string()
})
.collect()
};
assert_eq!(read(row_top, 3), "L1 ", "top visible row should be L1");
assert_eq!(read(row_bot, 3), "L24", "bottom visible row should be L24");
}
#[test]
fn ansi_escapes_render_as_styled_spans() {
let layout = Layout::new(160, 30, 1);
let mut backend = TestBackend::new(160, 30);
let mut terminal = Terminal::new(backend.clone()).unwrap();
let lines = vec![LineEntry {
text: "hello \x1b[1mbold\x1b[22m world".into(),
color: CC::White,
}];
terminal
.draw(|f| {
let area = f.area();
f.render_widget(ChatPane::new(&layout, &lines, 0), area);
})
.unwrap();
backend = terminal.backend().clone();
let row: String = (0..17)
.map(|i| {
backend
.buffer()
.cell((layout.chat.x + i, layout.chat.y))
.unwrap()
.symbol()
.to_string()
})
.collect();
assert_eq!(row, "hello bold world ", "got {:?}", row);
assert!(!row.contains('\x1b'), "raw ESC bytes leaked into buffer");
}
#[test]
fn color_translation_preserves_brightness() {
assert_eq!(crossterm_to_ratatui(CC::Green), RColor::LightGreen);
assert_eq!(crossterm_to_ratatui(CC::Red), RColor::LightRed);
assert_eq!(crossterm_to_ratatui(CC::Yellow), RColor::LightYellow);
assert_eq!(crossterm_to_ratatui(CC::Magenta), RColor::LightMagenta);
assert_eq!(crossterm_to_ratatui(CC::Cyan), RColor::LightCyan);
assert_eq!(crossterm_to_ratatui(CC::DarkGreen), RColor::Green);
assert_eq!(crossterm_to_ratatui(CC::DarkRed), RColor::Red);
assert_eq!(crossterm_to_ratatui(CC::DarkMagenta), RColor::Magenta);
assert_eq!(crossterm_to_ratatui(CC::DarkGrey), RColor::DarkGray);
assert_eq!(crossterm_to_ratatui(CC::Grey), RColor::Gray);
assert_eq!(
crossterm_to_ratatui(CC::Rgb { r: 1, g: 2, b: 3 }),
RColor::Rgb(1, 2, 3)
);
assert_eq!(crossterm_to_ratatui(CC::AnsiValue(42)), RColor::Indexed(42));
}
#[test]
fn selection_paints_reversed_on_selected_cells() {
use crate::ui::renderer::SelectionRange;
let layout = Layout::new(160, 30, 1);
let mut backend = TestBackend::new(160, 30);
let mut terminal = Terminal::new(backend.clone()).unwrap();
let lines = vec![line("hello world", CC::Green)];
let sel = SelectionRange {
start: (0, 6),
end: (0, 11),
};
terminal
.draw(|f| {
let area = f.area();
f.render_widget(ChatPane::new(&layout, &lines, 0).selection(sel), area);
})
.unwrap();
backend = terminal.backend().clone();
let row = layout.chat.y;
for i in 0..6 {
let cell = backend.buffer().cell((layout.chat.x + i, row)).unwrap();
assert!(
!cell.modifier.contains(Modifier::REVERSED),
"col {} should not be REVERSED",
layout.chat.x + i
);
}
for i in 6..11 {
let cell = backend.buffer().cell((layout.chat.x + i, row)).unwrap();
assert!(
cell.modifier.contains(Modifier::REVERSED),
"col {} should be REVERSED",
layout.chat.x + i
);
}
let cell = backend.buffer().cell((layout.chat.x + 11, row)).unwrap();
assert!(!cell.modifier.contains(Modifier::REVERSED));
}
}