use super::scroll_buffer::ScrollBuffer;
use crate::actor::Engine;
use crate::buffer::{Buffer, Cell, Rgb};
use crate::layout::Rect;
use std::io::Write;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone)]
pub struct StreamConfig {
pub max_scrollback: usize,
pub default_fg: Rgb,
pub default_bg: Rgb,
pub auto_scroll: bool,
pub word_wrap: bool,
}
impl Default for StreamConfig {
fn default() -> Self {
Self {
max_scrollback: 10000,
default_fg: Rgb::new(220, 220, 220),
default_bg: Rgb::DEFAULT_BG,
auto_scroll: true,
word_wrap: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppendResult {
FastPath {
chars: usize,
start_col: u16,
row: u16,
},
SlowPath {
dirty_rect: Rect,
},
Empty,
}
pub struct StreamWidget {
bounds: Rect,
config: StreamConfig,
content: ScrollBuffer,
cursor_col: u16,
cursor_row: u16,
current_fg: Rgb,
current_bg: Rgb,
needs_full_redraw: bool,
dirty_rects: Vec<Rect>,
}
impl StreamWidget {
pub fn new(bounds: Rect) -> Self {
Self::with_config(bounds, StreamConfig::default())
}
pub fn with_config(bounds: Rect, config: StreamConfig) -> Self {
Self {
bounds,
current_fg: config.default_fg,
current_bg: config.default_bg,
content: ScrollBuffer::new(config.max_scrollback),
config,
cursor_col: 0,
cursor_row: 0,
needs_full_redraw: true,
dirty_rects: Vec::new(),
}
}
pub const fn bounds(&self) -> Rect {
self.bounds
}
pub fn set_bounds(&mut self, bounds: Rect) {
if bounds != self.bounds {
let width_changed = bounds.width != self.bounds.width;
self.bounds = bounds;
self.needs_full_redraw = true;
if width_changed && bounds.width > 0 {
self.content.rewrap(bounds.width as usize);
}
}
}
pub const fn set_fg(&mut self, fg: Rgb) {
self.current_fg = fg;
}
pub const fn set_bg(&mut self, bg: Rgb) {
self.current_bg = bg;
}
pub const fn reset_colors(&mut self) {
self.current_fg = self.config.default_fg;
self.current_bg = self.config.default_bg;
}
fn can_fast_path(&self, text: &str) -> bool {
if !self.content.at_bottom() {
return false;
}
if text.contains('\n') {
return false;
}
let text_width = UnicodeWidthStr::width(text);
let available = (self.bounds.width as usize).saturating_sub(self.cursor_col as usize);
text_width <= available
}
fn append_fast_path(&mut self, text: &str) -> AppendResult {
let start_col = self.cursor_col;
let row = self.cursor_row;
let mut char_count = 0;
let cells = text.graphemes(true).filter_map(|g| {
Cell::from_grapheme(g).map(|mut c| {
c.set_fg(self.current_fg);
c.set_bg(self.current_bg);
c
})
});
self.content.append(cells);
for grapheme in text.graphemes(true) {
let width = UnicodeWidthStr::width(grapheme);
self.cursor_col += u16::try_from(width).unwrap_or(0);
char_count += 1;
}
AppendResult::FastPath {
chars: char_count,
start_col,
row,
}
}
fn append_slow_path(&mut self, text: &str) -> AppendResult {
let initial_row = self.cursor_row;
let mut max_row = self.cursor_row;
let initial_col = self.cursor_col;
let mut min_touched_col = self.cursor_col;
let mut max_col = self.cursor_col;
for ch in text.chars() {
match ch {
'\n' => {
let was_at_bottom = self.content.at_bottom();
self.content.newline(false);
if !was_at_bottom {
self.content.scroll_up(1);
}
max_col = max_col.max(self.cursor_col);
self.cursor_col = 0;
min_touched_col = 0; self.cursor_row += 1;
if self.cursor_row >= self.bounds.height {
self.handle_scroll(was_at_bottom);
}
}
'\r' => {
self.cursor_col = 0;
min_touched_col = 0;
}
'\t' => {
let spaces = 4 - (self.cursor_col % 4);
for _ in 0..spaces {
self.append_char(' ');
}
}
_ => {
self.append_char(ch);
}
}
max_row = max_row.max(self.cursor_row);
max_col = max_col.max(self.cursor_col);
if self.cursor_col < initial_col && self.cursor_row > initial_row {
min_touched_col = 0;
}
}
let dirty_rect = Rect {
x: self.bounds.x + min_touched_col,
y: self.bounds.y + initial_row,
width: self.bounds.width,
height: (max_row - initial_row + 1).max(1),
};
if !self.needs_full_redraw {
self.dirty_rects.push(dirty_rect);
}
AppendResult::SlowPath { dirty_rect }
}
#[allow(clippy::cast_possible_truncation)]
fn append_char(&mut self, ch: char) {
let char_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
if self.cursor_col + char_width > self.bounds.width {
if self.config.word_wrap {
let was_at_bottom = self.content.at_bottom();
self.content.newline(true);
if !was_at_bottom {
self.content.scroll_up(1);
}
self.cursor_col = 0;
self.cursor_row += 1;
if self.cursor_row >= self.bounds.height {
self.handle_scroll(was_at_bottom);
}
} else {
return;
}
}
let mut cell = Cell::from_char(ch);
cell.set_fg(self.current_fg);
cell.set_bg(self.current_bg);
self.content.append(std::iter::once(cell));
self.cursor_col += char_width;
}
const fn handle_scroll(&mut self, was_at_bottom: bool) {
self.cursor_row = self.bounds.height - 1;
if self.config.auto_scroll && was_at_bottom {
self.content.scroll_to_bottom();
}
self.needs_full_redraw = true;
}
pub fn append(&mut self, text: &str) -> AppendResult {
if text.is_empty() {
return AppendResult::Empty;
}
if self.can_fast_path(text) {
self.append_fast_path(text)
} else {
self.append_slow_path(text)
}
}
#[allow(clippy::cast_possible_truncation)]
pub fn render(&mut self, buffer: &mut Buffer) {
let viewport_height = self.bounds.height as usize;
let visible_lines: Vec<_> = self.content.visible_lines(viewport_height).collect();
for (row, line) in visible_lines.iter().enumerate() {
let y = self.bounds.y + row as u16;
if y >= self.bounds.y + self.bounds.height {
break;
}
let mut col = 0u16;
for cell in &line.content {
if col >= self.bounds.width {
break;
}
let x = self.bounds.x + col;
buffer.set(x, y, *cell);
col += u16::from(cell.display_width());
}
while col < self.bounds.width {
let x = self.bounds.x + col;
buffer.set(x, y, Cell::new(' ').with_fg(self.current_fg).with_bg(self.current_bg));
col += 1;
}
}
for row in visible_lines.len()..viewport_height {
let y = self.bounds.y + row as u16;
for col in 0..self.bounds.width {
let x = self.bounds.x + col;
buffer.set(x, y, Cell::new(' ').with_fg(self.current_fg).with_bg(self.current_bg));
}
}
self.needs_full_redraw = false;
self.dirty_rects.clear();
}
pub fn write_fast_path(
&self,
result: AppendResult,
text: &str,
output: &mut Vec<u8>,
) {
if let AppendResult::FastPath { start_col, row, .. } = result {
let abs_x = self.bounds.x + start_col + 1; let abs_y = self.bounds.y + row + 1;
let _ = write!(output, "\x1b[{abs_y};{abs_x}H");
let _ = write!(
output,
"\x1b[38;2;{};{};{}m\x1b[48;2;{};{};{}m",
self.current_fg.r, self.current_fg.g, self.current_fg.b,
self.current_bg.r, self.current_bg.g, self.current_bg.b
);
output.extend_from_slice(text.as_bytes());
}
}
pub fn append_fast_into(&mut self, text: &str, output: &mut Vec<u8>) -> bool {
let result = self.append(text);
if let AppendResult::FastPath { .. } = result {
self.write_fast_path(result, text, output);
true
} else {
false
}
}
pub fn push(&mut self, engine: &Engine, text: &str) {
let result = self.append(text);
if let AppendResult::FastPath { .. } = result {
let mut output = Vec::with_capacity(64);
self.write_fast_path(result, text, &mut output);
engine.write_raw(output);
}
}
pub const fn needs_redraw(&self) -> bool {
self.needs_full_redraw || !self.dirty_rects.is_empty()
}
pub fn dirty_rects(&self) -> &[Rect] {
&self.dirty_rects
}
pub const fn invalidate(&mut self) {
self.needs_full_redraw = true;
}
pub fn clear(&mut self) {
self.content.clear();
self.cursor_col = 0;
self.cursor_row = 0;
self.needs_full_redraw = true;
}
pub fn scroll_up(&mut self, lines: usize) {
self.content.scroll_up(lines);
self.needs_full_redraw = true;
}
pub const fn scroll_down(&mut self, lines: usize) {
self.content.scroll_down(lines);
self.needs_full_redraw = true;
}
pub const fn cursor_position(&self) -> (u16, u16) {
(self.cursor_col, self.cursor_row)
}
pub fn line_count(&self) -> usize {
self.content.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_stream_widget_new() {
let widget = StreamWidget::new(Rect::new(0, 0, 80, 24));
assert_eq!(widget.bounds().width, 80);
assert_eq!(widget.bounds().height, 24);
assert_eq!(widget.cursor_position(), (0, 0));
}
#[test]
fn test_stream_widget_append_fast_path() {
let mut widget = StreamWidget::new(Rect::new(0, 0, 80, 24));
let result = widget.append("Hello");
match result {
AppendResult::FastPath { chars, start_col, row } => {
assert_eq!(chars, 5);
assert_eq!(start_col, 0);
assert_eq!(row, 0);
}
_ => panic!("Expected fast path"),
}
assert_eq!(widget.cursor_position(), (5, 0));
}
#[test]
fn test_stream_widget_append_slow_path_newline() {
let mut widget = StreamWidget::new(Rect::new(0, 0, 80, 24));
let result = widget.append("Hello\nWorld");
match result {
AppendResult::SlowPath { .. } => {}
_ => panic!("Expected slow path due to newline"),
}
assert_eq!(widget.cursor_position(), (5, 1));
}
#[test]
fn test_stream_widget_wrap() {
let mut widget = StreamWidget::new(Rect::new(0, 0, 10, 24));
widget.append("12345678901234567890");
assert!(widget.cursor_row > 0);
}
#[test]
fn test_stream_widget_render() {
let mut widget = StreamWidget::new(Rect::new(0, 0, 10, 3));
widget.append("Line 1\nLine 2\nLine 3");
let mut buffer = Buffer::new(10, 3);
widget.render(&mut buffer);
let cell = buffer.get(0, 0).unwrap();
assert_eq!(cell.grapheme(), Some("L"));
}
}