use crate::ansi::AnsiWriter;
use crate::buffer::OptimizedBuffer;
use crate::color::Rgba;
use crate::grapheme_pool::GraphemePool;
use crate::link::LinkPool;
use crate::renderer::{BufferDiff, RendererOptions};
use crate::terminal::{CursorStyle, Terminal};
use std::io::{self, Stdout, Write};
use std::panic::AssertUnwindSafe;
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
#[allow(clippy::large_enum_variant)] enum RenderCommand {
Present {
buffer: OptimizedBuffer,
grapheme_pool: GraphemePool,
link_pool: LinkPool,
},
Resize { width: u32, height: u32 },
SetCursor { x: u32, y: u32, visible: bool },
SetCursorStyle { style: CursorStyle, blinking: bool },
SetTitle { title: String },
Invalidate,
Shutdown,
}
#[allow(clippy::large_enum_variant)] enum RenderReply {
BufferReady {
buffer: OptimizedBuffer,
grapheme_pool: GraphemePool,
link_pool: LinkPool,
},
ResizeComplete,
CursorComplete,
TitleComplete,
InvalidateComplete,
ShutdownComplete,
Error(String),
}
#[derive(Clone, Debug, Default)]
pub struct ThreadedRenderStats {
pub frames: u64,
pub last_frame_time: Duration,
pub last_frame_cells: usize,
pub fps: f32,
}
pub struct ThreadedRenderer {
tx: Sender<RenderCommand>,
rx: Receiver<RenderReply>,
handle: Option<JoinHandle<()>>,
back_buffer: OptimizedBuffer,
grapheme_pool: GraphemePool,
link_pool: LinkPool,
width: u32,
height: u32,
background: Rgba,
stats: ThreadedRenderStats,
last_present_at: Instant,
}
impl ThreadedRenderer {
pub fn new(width: u32, height: u32) -> io::Result<Self> {
Self::new_with_options(width, height, RendererOptions::default())
}
pub fn new_with_options(width: u32, height: u32, options: RendererOptions) -> io::Result<Self> {
let (tx, render_rx) = mpsc::channel::<RenderCommand>();
let (render_tx, rx) = mpsc::channel::<RenderReply>();
let handle = thread::Builder::new()
.name("opentui-render".to_string())
.spawn(move || {
render_thread_main(render_rx, render_tx, width, height, options);
})?;
Ok(Self {
tx,
rx,
handle: Some(handle),
back_buffer: OptimizedBuffer::new(width, height),
grapheme_pool: GraphemePool::new(),
link_pool: LinkPool::new(),
width,
height,
background: Rgba::BLACK,
stats: ThreadedRenderStats::default(),
last_present_at: Instant::now(),
})
}
#[must_use]
pub const fn size(&self) -> (u32, u32) {
(self.width, self.height)
}
pub fn buffer(&mut self) -> &mut OptimizedBuffer {
&mut self.back_buffer
}
pub fn grapheme_pool(&mut self) -> &mut GraphemePool {
&mut self.grapheme_pool
}
pub fn link_pool(&mut self) -> &mut LinkPool {
&mut self.link_pool
}
pub fn set_background(&mut self, color: Rgba) {
self.background = color;
}
pub fn clear(&mut self) {
self.back_buffer.clear(self.background);
}
#[must_use]
pub fn stats(&self) -> &ThreadedRenderStats {
&self.stats
}
pub fn present(&mut self) -> io::Result<()> {
let buffer = std::mem::replace(
&mut self.back_buffer,
OptimizedBuffer::new(self.width, self.height),
);
let grapheme_pool = std::mem::take(&mut self.grapheme_pool);
let link_pool = std::mem::take(&mut self.link_pool);
self.tx
.send(RenderCommand::Present {
buffer,
grapheme_pool,
link_pool,
})
.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "render thread disconnected"))?;
match self.rx.recv() {
Ok(RenderReply::BufferReady {
buffer,
grapheme_pool,
link_pool,
}) => {
self.back_buffer = buffer;
self.grapheme_pool = grapheme_pool;
self.link_pool = link_pool;
self.update_stats();
Ok(())
}
Ok(RenderReply::Error(msg)) => Err(io::Error::other(msg)),
Err(_) => Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"render thread disconnected",
)),
_ => Err(io::Error::new(
io::ErrorKind::InvalidData,
"unexpected reply",
)),
}
}
pub fn resize(&mut self, width: u32, height: u32) -> io::Result<()> {
self.tx
.send(RenderCommand::Resize { width, height })
.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "render thread disconnected"))?;
match self.rx.recv() {
Ok(RenderReply::ResizeComplete) => {
self.width = width;
self.height = height;
self.back_buffer = OptimizedBuffer::new(width, height);
Ok(())
}
Ok(RenderReply::Error(msg)) => Err(io::Error::other(msg)),
Err(_) => Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"render thread disconnected",
)),
_ => Err(io::Error::new(
io::ErrorKind::InvalidData,
"unexpected reply",
)),
}
}
pub fn set_cursor(&mut self, x: u32, y: u32, visible: bool) -> io::Result<()> {
self.tx
.send(RenderCommand::SetCursor { x, y, visible })
.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "render thread disconnected"))?;
match self.rx.recv() {
Ok(RenderReply::CursorComplete) => Ok(()),
Ok(RenderReply::Error(msg)) => Err(io::Error::other(msg)),
Err(_) => Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"render thread disconnected",
)),
_ => Err(io::Error::new(
io::ErrorKind::InvalidData,
"unexpected reply",
)),
}
}
pub fn set_cursor_style(&mut self, style: CursorStyle, blinking: bool) -> io::Result<()> {
self.tx
.send(RenderCommand::SetCursorStyle { style, blinking })
.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "render thread disconnected"))?;
match self.rx.recv() {
Ok(RenderReply::CursorComplete) => Ok(()),
Ok(RenderReply::Error(msg)) => Err(io::Error::other(msg)),
Err(_) => Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"render thread disconnected",
)),
_ => Err(io::Error::new(
io::ErrorKind::InvalidData,
"unexpected reply",
)),
}
}
pub fn set_title(&mut self, title: &str) -> io::Result<()> {
self.tx
.send(RenderCommand::SetTitle {
title: title.to_string(),
})
.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "render thread disconnected"))?;
match self.rx.recv() {
Ok(RenderReply::TitleComplete) => Ok(()),
Ok(RenderReply::Error(msg)) => Err(io::Error::other(msg)),
Err(_) => Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"render thread disconnected",
)),
_ => Err(io::Error::new(
io::ErrorKind::InvalidData,
"unexpected reply",
)),
}
}
pub fn invalidate(&mut self) -> io::Result<()> {
self.tx
.send(RenderCommand::Invalidate)
.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "render thread disconnected"))?;
match self.rx.recv() {
Ok(RenderReply::InvalidateComplete) => Ok(()),
Ok(RenderReply::Error(msg)) => Err(io::Error::other(msg)),
Err(_) => Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"render thread disconnected",
)),
_ => Err(io::Error::new(
io::ErrorKind::InvalidData,
"unexpected reply",
)),
}
}
pub fn shutdown(mut self) -> io::Result<()> {
self.shutdown_internal()
}
fn shutdown_internal(&mut self) -> io::Result<()> {
if self.tx.send(RenderCommand::Shutdown).is_err() {
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
return Ok(());
}
let _ = self.rx.recv_timeout(Duration::from_secs(5));
if let Some(handle) = self.handle.take() {
handle
.join()
.map_err(|_| io::Error::other("render thread panicked"))?;
}
Ok(())
}
fn update_stats(&mut self) {
let now = Instant::now();
let frame_time = now.duration_since(self.last_present_at);
self.last_present_at = now;
self.stats.frames = self.stats.frames.saturating_add(1);
self.stats.last_frame_time = frame_time;
self.stats.fps = if frame_time.as_secs_f32() > 0.0 {
1.0 / frame_time.as_secs_f32()
} else {
0.0
};
}
}
impl Drop for ThreadedRenderer {
fn drop(&mut self) {
if self.handle.is_some() {
let _ = self.tx.send(RenderCommand::Shutdown);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
}
fn render_thread_main(
rx: Receiver<RenderCommand>,
tx: Sender<RenderReply>,
width: u32,
height: u32,
options: RendererOptions,
) {
let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
render_thread_inner(rx, tx.clone(), width, height, options)
}));
if let Err(e) = result {
let msg = panic_message(e.as_ref());
let _ = tx.send(RenderReply::Error(msg));
let mut terminal = create_terminal();
let _ = terminal.cleanup();
}
}
fn create_terminal() -> Terminal<Stdout> {
Terminal::new(io::stdout())
}
fn panic_message(payload: &dyn std::any::Any) -> String {
payload
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "render thread panicked".to_string())
}
#[allow(clippy::too_many_lines)] fn render_thread_inner(
rx: Receiver<RenderCommand>,
tx: Sender<RenderReply>,
width: u32,
height: u32,
options: RendererOptions,
) {
let mut terminal = create_terminal();
if options.use_alt_screen {
if let Err(e) = terminal.enter_alt_screen() {
let _ = tx.send(RenderReply::Error(format!(
"failed to enter alt screen: {e}"
)));
return;
}
}
if options.hide_cursor {
let _ = terminal.hide_cursor();
}
if options.enable_mouse {
let _ = terminal.enable_mouse();
}
if options.query_capabilities {
let _ = terminal.query_capabilities();
}
let mut front_buffer = OptimizedBuffer::new(width, height);
let mut force_redraw = true;
let mut scratch_buffer: Vec<u8> = Vec::with_capacity(
(width as usize)
.saturating_mul(height as usize)
.saturating_mul(20),
);
let mut current_width = width;
let mut current_height = height;
loop {
match rx.recv() {
Ok(RenderCommand::Present {
buffer,
grapheme_pool,
link_pool,
}) => {
let total_cells = (current_width as usize).saturating_mul(current_height as usize);
let diff = BufferDiff::compute(&front_buffer, &buffer);
let render_result = if force_redraw || diff.should_full_redraw(total_cells) {
render_full(
&mut terminal,
&mut scratch_buffer,
&buffer,
&grapheme_pool,
&link_pool,
current_width,
current_height,
)
} else {
render_diff(
&mut terminal,
&mut scratch_buffer,
&buffer,
&grapheme_pool,
&link_pool,
&diff,
)
};
if let Err(e) = render_result {
let _ = tx.send(RenderReply::Error(format!("render error: {e}")));
let _ = tx.send(RenderReply::BufferReady {
buffer,
grapheme_pool,
link_pool,
});
continue;
}
force_redraw = false;
front_buffer = buffer.clone();
let _ = tx.send(RenderReply::BufferReady {
buffer,
grapheme_pool,
link_pool,
});
}
Ok(RenderCommand::Resize { width, height }) => {
current_width = width;
current_height = height;
front_buffer = OptimizedBuffer::new(width, height);
scratch_buffer = Vec::with_capacity(
(width as usize)
.saturating_mul(height as usize)
.saturating_mul(20),
);
force_redraw = true;
let _ = terminal.clear();
let _ = tx.send(RenderReply::ResizeComplete);
}
Ok(RenderCommand::SetCursor { x, y, visible }) => {
if visible {
let _ = terminal.show_cursor();
let _ = terminal.move_cursor(x, y);
} else {
let _ = terminal.hide_cursor();
}
let _ = tx.send(RenderReply::CursorComplete);
}
Ok(RenderCommand::SetCursorStyle { style, blinking }) => {
let _ = terminal.set_cursor_style(style, blinking);
let _ = tx.send(RenderReply::CursorComplete);
}
Ok(RenderCommand::SetTitle { title }) => {
let _ = terminal.set_title(&title);
let _ = tx.send(RenderReply::TitleComplete);
}
Ok(RenderCommand::Invalidate) => {
force_redraw = true;
let _ = tx.send(RenderReply::InvalidateComplete);
}
Ok(RenderCommand::Shutdown) => {
let _ = terminal.cleanup();
let _ = tx.send(RenderReply::ShutdownComplete);
break;
}
Err(_) => {
let _ = terminal.cleanup();
break;
}
}
}
}
fn render_full(
terminal: &mut Terminal<Stdout>,
scratch: &mut Vec<u8>,
buffer: &OptimizedBuffer,
grapheme_pool: &GraphemePool,
link_pool: &LinkPool,
width: u32,
height: u32,
) -> io::Result<()> {
if terminal.capabilities().sync_output {
terminal.begin_sync()?;
}
scratch.clear();
let mut writer = AnsiWriter::new(&mut *scratch);
writer.write_str("\x1b[H");
for y in 0..height {
writer.move_cursor(y, 0);
for x in 0..width {
if let Some(cell) = buffer.get(x, y) {
if !cell.is_continuation() {
let url = cell.attributes.link_id().and_then(|id| link_pool.get(id));
writer.write_cell_with_pool_and_link(cell, grapheme_pool, url);
}
}
}
}
writer.reset();
writer.flush()?;
terminal.flush()?;
io::stdout().write_all(scratch)?;
io::stdout().flush()?;
if terminal.capabilities().sync_output {
terminal.end_sync()?;
}
terminal.flush()
}
fn render_diff(
terminal: &mut Terminal<Stdout>,
scratch: &mut Vec<u8>,
buffer: &OptimizedBuffer,
grapheme_pool: &GraphemePool,
link_pool: &LinkPool,
diff: &BufferDiff,
) -> io::Result<()> {
if terminal.capabilities().sync_output {
terminal.begin_sync()?;
}
scratch.clear();
let mut writer = AnsiWriter::new(&mut *scratch);
writer.write_str("\x1b[H");
for &(x, y) in &diff.changed_cells {
if let Some(cell) = buffer.get(x, y) {
if !cell.is_continuation() {
let url = cell.attributes.link_id().and_then(|id| link_pool.get(id));
writer.write_cell_at_with_pool_and_link(y, x, cell, grapheme_pool, url);
}
}
}
writer.reset();
writer.flush()?;
if !scratch.is_empty() {
io::stdout().write_all(scratch)?;
io::stdout().flush()?;
}
if terminal.capabilities().sync_output {
terminal.end_sync()?;
}
terminal.flush()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_stats_default() {
let stats = ThreadedRenderStats::default();
assert_eq!(stats.frames, 0);
assert_eq!(stats.last_frame_cells, 0);
assert_eq!(stats.last_frame_time, Duration::ZERO);
assert!(stats.fps.abs() < f32::EPSILON);
}
#[test]
fn test_stats_clone() {
let stats = ThreadedRenderStats {
frames: 100,
fps: 60.0,
last_frame_cells: 1920,
last_frame_time: Duration::from_millis(16),
};
let cloned = stats.clone();
assert_eq!(cloned.frames, 100);
assert!((cloned.fps - 60.0).abs() < f32::EPSILON);
assert_eq!(cloned.last_frame_cells, 1920);
assert_eq!(cloned.last_frame_time, Duration::from_millis(16));
}
#[test]
fn test_stats_debug() {
let stats = ThreadedRenderStats::default();
let debug_str = format!("{stats:?}");
assert!(debug_str.contains("ThreadedRenderStats"));
assert!(debug_str.contains("frames"));
assert!(debug_str.contains("fps"));
}
#[test]
fn test_render_command_sizes() {
use std::mem::size_of;
let cmd_size = size_of::<RenderCommand>();
eprintln!("[TEST] RenderCommand size: {cmd_size} bytes");
assert!(cmd_size > 0);
let reply_size = size_of::<RenderReply>();
eprintln!("[TEST] RenderReply size: {reply_size} bytes");
assert!(reply_size > 0);
}
#[test]
fn test_render_options_default() {
let opts = RendererOptions::default();
assert!(opts.use_alt_screen);
assert!(opts.hide_cursor);
assert!(opts.enable_mouse);
assert!(opts.query_capabilities);
}
#[test]
fn test_render_options_custom() {
let opts = RendererOptions {
use_alt_screen: false,
hide_cursor: false,
enable_mouse: false,
query_capabilities: false,
};
assert!(!opts.use_alt_screen);
assert!(!opts.hide_cursor);
assert!(!opts.enable_mouse);
assert!(!opts.query_capabilities);
}
}