use std::io;
use std::io::{Read, Write};
use std::sync::{
Arc, Mutex, RwLock,
atomic::{AtomicBool, Ordering},
};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use crate::ansi::AnsiDecoder;
use crate::console::{Console, ConsoleOptions, RenderHook};
use crate::renderables::Renderable;
use crate::segment::{ControlCode, ControlType, Segment, split_lines};
use crate::style::Style;
use crate::sync::{lock_recover, read_recover, write_recover};
use crate::text::{JustifyMethod, OverflowMethod, Text};
use os_pipe::PipeReader;
use stdio_override::{StderrOverride, StdoutOverride};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum VerticalOverflowMethod {
Crop,
#[default]
Ellipsis,
Visible,
}
#[derive(Debug, Clone)]
pub struct LiveOptions {
pub screen: bool,
pub auto_refresh: bool,
pub refresh_per_second: f64,
pub transient: bool,
pub redirect_stdout: bool,
pub redirect_stderr: bool,
pub vertical_overflow: VerticalOverflowMethod,
}
impl Default for LiveOptions {
fn default() -> Self {
Self {
screen: false,
auto_refresh: true,
refresh_per_second: 4.0,
transient: false,
redirect_stdout: true,
redirect_stderr: true,
vertical_overflow: VerticalOverflowMethod::Ellipsis,
}
}
}
#[derive(Clone)]
pub struct Live {
inner: Arc<LiveInner>,
}
#[derive(Clone)]
pub struct LiveWriter {
console: Arc<Console>,
buffer: Vec<u8>,
decoder: AnsiDecoder,
}
impl LiveWriter {
#[must_use]
pub fn new(console: Arc<Console>) -> Self {
Self {
console,
buffer: Vec::new(),
decoder: AnsiDecoder::new(),
}
}
fn normalize_trailing_cr(line: &str) -> &str {
line.strip_suffix('\r').unwrap_or(line)
}
}
impl io::Write for LiveWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.buffer.extend_from_slice(buf);
let mut lines: Vec<Text> = Vec::new();
while let Some(pos) = self.buffer.iter().position(|&b| b == b'\n') {
let line_bytes: Vec<u8> = self.buffer.drain(..pos).collect();
let _ = self.buffer.drain(..1);
let line = String::from_utf8_lossy(&line_bytes);
lines.push(
self.decoder
.decode_line(Self::normalize_trailing_cr(line.as_ref())),
);
}
if !lines.is_empty() {
let sep = Text::new("\n");
let mut joined = sep.join(lines.iter());
joined.end = "\n".to_string();
self.console.print_text(&joined);
}
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
if !self.buffer.is_empty() {
let remainder = String::from_utf8_lossy(&self.buffer).to_string();
self.buffer.clear();
let mut decoded = self
.decoder
.decode_line(Self::normalize_trailing_cr(&remainder));
decoded.end = "\n".to_string();
self.console.print_text(&decoded);
}
Ok(())
}
}
type RenderableFactory = Arc<dyn Fn() -> Box<dyn Renderable + Send + Sync> + Send + Sync>;
pub(crate) struct LiveInner {
console: Arc<Console>,
options: Mutex<LiveOptions>,
renderable: RwLock<Option<Box<dyn Renderable + Send + Sync>>>,
get_renderable: Mutex<Option<RenderableFactory>>,
started: AtomicBool,
nested: AtomicBool,
alt_screen_active: AtomicBool,
refresh_stop: Arc<AtomicBool>,
refresh_thread: Mutex<Option<JoinHandle<()>>>,
live_render: Mutex<LiveRender>,
stdio_redirect: Mutex<Option<StdioRedirect>>,
}
impl Live {
#[must_use]
pub fn new(console: Arc<Console>) -> Self {
Self::with_options(console, LiveOptions::default())
}
#[must_use]
pub fn with_options(console: Arc<Console>, options: LiveOptions) -> Self {
assert!(
options.refresh_per_second > 0.0,
"refresh_per_second must be > 0"
);
let mut options = options;
if options.screen {
options.transient = true;
}
Self {
inner: Arc::new(LiveInner {
console,
options: Mutex::new(options),
renderable: RwLock::new(None),
get_renderable: Mutex::new(None),
started: AtomicBool::new(false),
nested: AtomicBool::new(false),
alt_screen_active: AtomicBool::new(false),
refresh_stop: Arc::new(AtomicBool::new(false)),
refresh_thread: Mutex::new(None),
live_render: Mutex::new(LiveRender::default()),
stdio_redirect: Mutex::new(None),
}),
}
}
#[must_use]
pub fn renderable<R>(self, renderable: R) -> Self
where
R: Renderable + Send + Sync + 'static,
{
*write_recover(&self.inner.renderable) = Some(Box::new(renderable));
self
}
#[must_use]
pub fn get_renderable<F>(self, callback: F) -> Self
where
F: Fn() -> Box<dyn Renderable + Send + Sync> + Send + Sync + 'static,
{
*lock_recover(&self.inner.get_renderable) = Some(Arc::new(callback));
self
}
pub fn start(&self, refresh: bool) -> io::Result<()> {
if self.inner.started.swap(true, Ordering::SeqCst) {
return Ok(());
}
if !self.inner.console.set_live(&self.inner) {
self.inner.nested.store(true, Ordering::SeqCst);
return Ok(());
}
let options = self.inner.options();
if options.screen {
self.inner.console.set_alt_screen(true)?;
self.inner.alt_screen_active.store(true, Ordering::SeqCst);
}
self.inner.console.show_cursor(false)?;
self.inner.maybe_start_stdio_redirect()?;
self.inner
.console
.push_render_hook(Arc::clone(&self.inner) as Arc<dyn RenderHook>);
if refresh {
self.refresh()?;
}
if options.auto_refresh {
Arc::clone(&self.inner).start_refresh_thread();
}
Ok(())
}
pub fn stop(&self) -> io::Result<()> {
if !self.inner.started.swap(false, Ordering::SeqCst) {
return Ok(());
}
self.inner.stop_refresh_thread();
self.inner.console.clear_live();
if self.inner.nested.load(Ordering::SeqCst) {
return Ok(());
}
{
let mut options = self.inner.options_mut();
options.vertical_overflow = VerticalOverflowMethod::Visible;
}
if !self.inner.alt_screen_active.load(Ordering::SeqCst) && self.inner.console.is_terminal()
{
let _ = self.refresh();
self.inner.console.line();
}
self.inner.console.pop_render_hook();
let _ = self.inner.console.show_cursor(true);
self.inner.stop_stdio_redirect();
if self.inner.alt_screen_active.swap(false, Ordering::SeqCst) {
let _ = self.inner.console.set_alt_screen(false);
}
if self.inner.options().transient && !self.inner.alt_screen_active.load(Ordering::SeqCst) {
let controls = self.inner.live_render_controls_restore();
let _ = self.inner.console.write_control_codes(controls);
}
Ok(())
}
pub fn update<R>(&self, renderable: R, refresh: bool)
where
R: Renderable + Send + Sync + 'static,
{
*write_recover(&self.inner.renderable) = Some(Box::new(renderable));
if refresh {
let _ = self.refresh();
}
}
pub fn refresh(&self) -> io::Result<()> {
self.inner.refresh_display()
}
#[must_use]
pub fn stdout_proxy(&self) -> LiveWriter {
LiveWriter::new(Arc::clone(&self.inner.console))
}
#[must_use]
pub fn stderr_proxy(&self) -> LiveWriter {
LiveWriter::new(Arc::clone(&self.inner.console))
}
}
impl Drop for Live {
fn drop(&mut self) {
let _ = self.stop();
}
}
impl LiveInner {
fn options(&self) -> LiveOptions {
lock_recover(&self.options).clone()
}
fn options_mut(&self) -> std::sync::MutexGuard<'_, LiveOptions> {
lock_recover(&self.options)
}
fn maybe_start_stdio_redirect(&self) -> io::Result<()> {
let options = self.options();
if !options.redirect_stdout && !options.redirect_stderr {
return Ok(());
}
if !self.console.is_terminal_detected() || self.console.is_dumb_terminal() {
return Ok(());
}
if lock_recover(&self.stdio_redirect).is_some() {
return Ok(());
}
let mut redirect = StdioRedirect::start(
&self.console,
options.redirect_stdout,
options.redirect_stderr,
)?;
if let Some(stdout) = redirect.stdout_override.clone() {
let original = self
.console
.swap_file(Box::new(OverrideWriter { inner: stdout }));
redirect.console_original_writer = Some(original);
}
*lock_recover(&self.stdio_redirect) = Some(redirect);
Ok(())
}
fn stop_stdio_redirect(&self) {
let mut slot = lock_recover(&self.stdio_redirect);
let Some(mut redirect) = slot.take() else {
return;
};
if let Some(original) = redirect.console_original_writer.take() {
let _ = self.console.swap_file(original);
}
redirect.stop();
}
fn current_renderable(
&self,
console: &Console,
options: &ConsoleOptions,
) -> Vec<Segment<'static>> {
let callback = lock_recover(&self.get_renderable).clone();
if let Some(callback) = callback {
let renderable = callback();
return renderable
.render(console, options)
.into_iter()
.map(Segment::into_owned)
.collect();
}
{
let slot = read_recover(&self.renderable);
if let Some(renderable) = slot.as_ref() {
return renderable
.render(console, options)
.into_iter()
.map(Segment::into_owned)
.collect();
}
}
Vec::new()
}
fn render_stack_segments(
&self,
console: &Console,
options: &ConsoleOptions,
) -> Vec<Segment<'static>> {
let lives = console.live_stack_snapshot();
if lives.is_empty() {
return self.current_renderable(console, options);
}
let mut output = Vec::new();
for (idx, live) in lives.iter().enumerate() {
let segments = live.current_renderable(console, options);
if idx > 0 && !segments.is_empty() {
output.push(Segment::line());
}
output.extend(segments);
}
output
}
fn live_render_controls_restore(&self) -> Vec<ControlCode> {
lock_recover(&self.live_render).restore_cursor_controls()
}
fn render_live_segments(
&self,
render: &mut LiveRender,
console: &Console,
options: &ConsoleOptions,
vertical_overflow: VerticalOverflowMethod,
) -> Vec<Segment<'static>> {
let raw_segments = self.render_stack_segments(console, options);
let mut lines = split_lines(raw_segments.into_iter());
let max_height = options.size.height;
let mut needs_ellipsis = false;
if max_height > 0 && lines.len() > max_height {
match vertical_overflow {
VerticalOverflowMethod::Crop => {
lines.truncate(max_height);
}
VerticalOverflowMethod::Ellipsis => {
if max_height == 1 {
lines.truncate(1);
} else {
lines.truncate(max_height - 1);
needs_ellipsis = true;
}
}
VerticalOverflowMethod::Visible => {}
}
}
if needs_ellipsis {
let width = options.max_width;
let mut ellipsis = Text::styled("...", Style::new().dim());
ellipsis.overflow = OverflowMethod::Crop;
ellipsis.justify = JustifyMethod::Center;
ellipsis.pad(width, JustifyMethod::Center);
let ellipsis_segments = ellipsis
.render("")
.into_iter()
.map(Segment::into_owned)
.collect();
lines.push(ellipsis_segments);
}
let mut max_width = 0usize;
for line in &lines {
let line_width: usize = line.iter().map(Segment::cell_length).sum();
max_width = max_width.max(line_width);
}
render.shape = Some((max_width, lines.len()));
let mut flattened = Vec::new();
let last_index = lines.len().saturating_sub(1);
for (idx, mut line) in lines.into_iter().enumerate() {
flattened.append(&mut line);
if idx < last_index {
flattened.push(Segment::line());
}
}
flattened
}
fn start_refresh_thread(self: &Arc<Self>) {
if self.refresh_stop.load(Ordering::Relaxed) {
self.refresh_stop.store(false, Ordering::Relaxed);
}
let inner = Arc::clone(self);
let interval = {
let options = self.options();
Duration::from_secs_f64(1.0 / options.refresh_per_second)
};
let stop = Arc::clone(&self.refresh_stop);
let handle = thread::spawn(move || {
while !stop.load(Ordering::Relaxed) {
thread::sleep(interval);
if !stop.load(Ordering::Relaxed) {
let _ = inner.refresh_display();
}
}
});
*lock_recover(&self.refresh_thread) = Some(handle);
}
fn stop_refresh_thread(&self) {
self.refresh_stop.store(true, Ordering::Relaxed);
if let Some(handle) = lock_recover(&self.refresh_thread).take() {
let _ = handle.join();
}
}
fn refresh_display(&self) -> io::Result<()> {
if self.nested.load(Ordering::SeqCst) {
if let Some(parent) = self.console.live_stack_snapshot().first() {
return parent.refresh_display();
}
return Ok(());
}
if (self.console.is_terminal() && !self.console.is_dumb_terminal())
|| !self.options().transient
{
self.console.print_segments(&[]);
}
Ok(())
}
}
#[derive(Clone)]
struct OverrideWriter<T> {
inner: Arc<Mutex<T>>,
}
impl<T: Write> Write for OverrideWriter<T> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
lock_recover(&*self.inner).write(buf)
}
fn flush(&mut self) -> io::Result<()> {
lock_recover(&*self.inner).flush()
}
}
struct StdioRedirect {
stop: Arc<AtomicBool>,
stdout_override: Option<Arc<Mutex<StdoutOverride>>>,
stderr_override: Option<Arc<Mutex<StderrOverride>>>,
stdout_reader: Option<JoinHandle<()>>,
stderr_reader: Option<JoinHandle<()>>,
pump: Option<JoinHandle<()>>,
console_original_writer: Option<Box<dyn Write + Send>>,
}
impl StdioRedirect {
fn start(
console: &Arc<Console>,
redirect_stdout: bool,
redirect_stderr: bool,
) -> io::Result<Self> {
let stop = Arc::new(AtomicBool::new(false));
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
let pump_console = Arc::clone(console);
let pump_stop = Arc::clone(&stop);
let pump = thread::spawn(move || {
let mut carry: Vec<u8> = Vec::new();
while !pump_stop.load(Ordering::Relaxed) {
let Ok(mut chunk) = rx.recv() else {
break;
};
if chunk.is_empty() {
continue;
}
if !carry.is_empty() {
carry.append(&mut chunk);
chunk = std::mem::take(&mut carry);
}
match String::from_utf8(chunk) {
Ok(s) => {
if !s.is_empty() {
pump_console.print_plain(&s);
}
}
Err(e) => {
let valid = e.utf8_error().valid_up_to();
let bytes = e.into_bytes();
let (ok_bytes, rest) = bytes.split_at(valid);
if !ok_bytes.is_empty() {
let s = String::from_utf8_lossy(ok_bytes);
pump_console.print_plain(&s);
}
carry.extend_from_slice(rest);
}
}
}
if !carry.is_empty() {
let s = String::from_utf8_lossy(&carry);
pump_console.print_plain(&s);
}
});
let mut stdout_override = None;
let mut stderr_override = None;
let mut stdout_reader = None;
let mut stderr_reader = None;
if redirect_stdout {
let (reader, writer) = os_pipe::pipe()?;
let guard = StdoutOverride::from_io(writer)?;
let guard = Arc::new(Mutex::new(guard));
stdout_override = Some(Arc::clone(&guard));
stdout_reader = Some(Self::start_reader_thread(
reader,
tx.clone(),
Arc::clone(&stop),
));
}
if redirect_stderr {
let (reader, writer) = os_pipe::pipe()?;
let guard = StderrOverride::from_io(writer)?;
let guard = Arc::new(Mutex::new(guard));
stderr_override = Some(Arc::clone(&guard));
stderr_reader = Some(Self::start_reader_thread(
reader,
tx.clone(),
Arc::clone(&stop),
));
}
Ok(Self {
stop,
stdout_override,
stderr_override,
stdout_reader,
stderr_reader,
pump: Some(pump),
console_original_writer: None,
})
}
fn start_reader_thread(
mut reader: PipeReader,
tx: std::sync::mpsc::Sender<Vec<u8>>,
stop: Arc<AtomicBool>,
) -> JoinHandle<()> {
thread::spawn(move || {
let mut buf = [0u8; 8192];
while !stop.load(Ordering::Relaxed) {
match reader.read(&mut buf) {
Ok(0) => break, Ok(n) => {
let _ = tx.send(buf[..n].to_vec());
}
Err(_) => break,
}
}
})
}
fn stop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
self.stderr_override.take();
self.stdout_override.take();
if let Some(h) = self.stdout_reader.take() {
let _ = h.join();
}
if let Some(h) = self.stderr_reader.take() {
let _ = h.join();
}
if let Some(h) = self.pump.take() {
let _ = h.join();
}
}
}
impl RenderHook for LiveInner {
fn process(&self, console: &Console, segments: &[Segment<'static>]) -> Vec<Segment<'static>> {
let options = console.options();
let overflow = self.options().vertical_overflow;
let mut render = lock_recover(&self.live_render);
let mut output = Vec::new();
if console.is_interactive() {
if self.alt_screen_active.load(Ordering::SeqCst) {
output.push(Segment::control(vec![ControlCode::new(ControlType::Home)]));
} else {
let controls = render.position_cursor_controls();
if !controls.is_empty() {
output.push(Segment::control(controls));
}
}
output.extend_from_slice(segments);
let live_segments = self.render_live_segments(&mut render, console, &options, overflow);
output.extend(live_segments);
output
} else if !self.options().transient {
output.extend_from_slice(segments);
let live_segments = self.render_live_segments(&mut render, console, &options, overflow);
output.extend(live_segments);
output
} else {
segments.to_vec()
}
}
}
#[derive(Debug, Default)]
pub(crate) struct LiveRender {
shape: Option<(usize, usize)>,
}
impl LiveRender {
fn position_cursor_controls(&self) -> Vec<ControlCode> {
let Some((_, height)) = self.shape else {
return Vec::new();
};
if height == 0 {
return Vec::new();
}
let mut controls = Vec::new();
controls.push(ControlCode::new(ControlType::CarriageReturn));
controls.push(ControlCode::with_params_vec(
ControlType::EraseInLine,
vec![2],
));
if height > 1 {
for _ in 0..(height - 1) {
controls.push(ControlCode::with_params_vec(ControlType::CursorUp, vec![1]));
controls.push(ControlCode::with_params_vec(
ControlType::EraseInLine,
vec![2],
));
}
}
controls
}
fn restore_cursor_controls(&self) -> Vec<ControlCode> {
let Some((_, height)) = self.shape else {
return Vec::new();
};
if height == 0 {
return Vec::new();
}
let mut controls = Vec::new();
controls.push(ControlCode::new(ControlType::CarriageReturn));
for _ in 0..height {
controls.push(ControlCode::with_params_vec(ControlType::CursorUp, vec![1]));
controls.push(ControlCode::with_params_vec(
ControlType::EraseInLine,
vec![2],
));
}
controls
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color::ColorSystem;
use std::io::Write;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
#[derive(Clone)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl SharedBuffer {
fn new() -> Self {
Self(Arc::new(Mutex::new(Vec::new())))
}
fn text(&self) -> String {
let buf = self.0.lock().unwrap();
String::from_utf8_lossy(&buf).to_string()
}
#[allow(dead_code)]
fn clear(&self) {
self.0.lock().unwrap().clear();
}
}
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
fn make_test_console(buffer: SharedBuffer) -> Arc<Console> {
Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer))
.build()
.shared()
}
#[test]
fn test_live_options_default() {
let options = LiveOptions::default();
assert!(!options.screen);
assert!(options.auto_refresh);
assert!((options.refresh_per_second - 4.0).abs() < f64::EPSILON);
assert!(!options.transient);
assert!(options.redirect_stdout);
assert!(options.redirect_stderr);
assert_eq!(options.vertical_overflow, VerticalOverflowMethod::Ellipsis);
}
#[test]
fn test_live_options_custom() {
let options = LiveOptions {
screen: true,
auto_refresh: false,
refresh_per_second: 10.0,
transient: true,
redirect_stdout: false,
redirect_stderr: false,
vertical_overflow: VerticalOverflowMethod::Crop,
};
assert!(options.screen);
assert!(!options.auto_refresh);
assert!((options.refresh_per_second - 10.0).abs() < f64::EPSILON);
assert!(options.transient);
assert!(!options.redirect_stdout);
assert!(!options.redirect_stderr);
assert_eq!(options.vertical_overflow, VerticalOverflowMethod::Crop);
}
#[test]
fn test_vertical_overflow_default() {
let method = VerticalOverflowMethod::default();
assert_eq!(method, VerticalOverflowMethod::Ellipsis);
}
#[test]
fn test_vertical_overflow_variants() {
let crop = VerticalOverflowMethod::Crop;
let ellipsis = VerticalOverflowMethod::Ellipsis;
let visible = VerticalOverflowMethod::Visible;
assert_ne!(crop, ellipsis);
assert_ne!(ellipsis, visible);
assert_ne!(crop, visible);
}
#[test]
fn test_live_new() {
let buffer = SharedBuffer::new();
let console = make_test_console(buffer);
let live = Live::new(console);
assert!(!live.inner.started.load(Ordering::SeqCst));
}
#[test]
fn test_live_with_options() {
let buffer = SharedBuffer::new();
let console = make_test_console(buffer);
let options = LiveOptions {
auto_refresh: false,
refresh_per_second: 2.0,
..LiveOptions::default()
};
let live = Live::with_options(console, options);
let stored = live.inner.options();
assert!(!stored.auto_refresh);
assert!((stored.refresh_per_second - 2.0).abs() < f64::EPSILON);
}
#[test]
fn test_live_screen_enables_transient() {
let buffer = SharedBuffer::new();
let console = make_test_console(buffer);
let options = LiveOptions {
screen: true,
transient: false, ..LiveOptions::default()
};
let live = Live::with_options(console, options);
let stored = live.inner.options();
assert!(
stored.transient,
"transient should be true when screen is true"
);
}
#[test]
#[should_panic(expected = "refresh_per_second must be > 0")]
fn test_live_zero_refresh_rate_panics() {
let buffer = SharedBuffer::new();
let console = make_test_console(buffer);
let options = LiveOptions {
refresh_per_second: 0.0,
..LiveOptions::default()
};
let _live = Live::with_options(console, options);
}
#[test]
#[should_panic(expected = "refresh_per_second must be > 0")]
fn test_live_negative_refresh_rate_panics() {
let buffer = SharedBuffer::new();
let console = make_test_console(buffer);
let options = LiveOptions {
refresh_per_second: -1.0,
..LiveOptions::default()
};
let _live = Live::with_options(console, options);
}
#[test]
fn test_live_renderable_builder() {
let buffer = SharedBuffer::new();
let console = make_test_console(buffer);
let live = Live::new(console).renderable(Text::new("Content"));
let slot = live.inner.renderable.read().unwrap();
assert!(slot.is_some());
}
#[test]
fn test_live_get_renderable_callback() {
let buffer = SharedBuffer::new();
let console = make_test_console(buffer);
let counter = Arc::new(Mutex::new(0));
let counter_clone = Arc::clone(&counter);
let live = Live::new(console).get_renderable(move || {
let mut c = counter_clone.lock().unwrap();
*c += 1;
Box::new(Text::new(format!("Count: {}", *c)))
});
let slot = live.inner.get_renderable.lock().unwrap();
assert!(slot.is_some());
}
#[test]
fn test_live_refresh_outputs_renderable() {
let buffer = SharedBuffer::new();
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let options = LiveOptions {
auto_refresh: false,
screen: false,
transient: false,
..LiveOptions::default()
};
let live = Live::with_options(console, options).renderable(Text::new("Hello"));
live.start(true).expect("start");
let _ = live.refresh();
live.stop().expect("stop");
let text = buffer.text();
assert!(text.contains("Hello"), "output missing: {text}");
}
#[test]
fn test_live_start_stop() {
let buffer = SharedBuffer::new();
let console = make_test_console(buffer);
let options = LiveOptions {
auto_refresh: false,
..LiveOptions::default()
};
let live = Live::with_options(console, options);
assert!(!live.inner.started.load(Ordering::SeqCst));
live.start(false).expect("start should succeed");
assert!(live.inner.started.load(Ordering::SeqCst));
live.stop().expect("stop should succeed");
assert!(!live.inner.started.load(Ordering::SeqCst));
}
#[test]
fn test_live_start_idempotent() {
let buffer = SharedBuffer::new();
let console = make_test_console(buffer);
let options = LiveOptions {
auto_refresh: false,
..LiveOptions::default()
};
let live = Live::with_options(console, options);
live.start(false).expect("first start");
live.start(false).expect("second start should be no-op");
live.start(false).expect("third start should be no-op");
assert!(live.inner.started.load(Ordering::SeqCst));
live.stop().expect("stop");
}
#[test]
fn test_live_stop_idempotent() {
let buffer = SharedBuffer::new();
let console = make_test_console(buffer);
let options = LiveOptions {
auto_refresh: false,
..LiveOptions::default()
};
let live = Live::with_options(console, options);
live.start(false).expect("start");
live.stop().expect("first stop");
live.stop().expect("second stop should be no-op");
live.stop().expect("third stop should be no-op");
assert!(!live.inner.started.load(Ordering::SeqCst));
}
#[test]
fn test_live_drop_stops() {
let buffer = SharedBuffer::new();
let console = make_test_console(buffer);
let options = LiveOptions {
auto_refresh: false,
..LiveOptions::default()
};
{
let live = Live::with_options(console.clone(), options);
live.start(false).expect("start");
assert!(live.inner.started.load(Ordering::SeqCst));
}
}
#[test]
fn test_live_update_renderable() {
let buffer = SharedBuffer::new();
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let options = LiveOptions {
auto_refresh: false,
screen: false,
transient: false,
..LiveOptions::default()
};
let live = Live::with_options(console, options).renderable(Text::new("First"));
live.start(true).expect("start");
live.update(Text::new("Second"), true);
live.stop().expect("stop");
let text = buffer.text();
assert!(
text.contains("Second"),
"should contain updated content: {text}"
);
}
#[test]
fn test_live_update_without_refresh() {
let buffer = SharedBuffer::new();
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let options = LiveOptions {
auto_refresh: false,
screen: false,
transient: false,
..LiveOptions::default()
};
let live = Live::with_options(console, options).renderable(Text::new("Initial"));
live.start(false).expect("start");
live.update(Text::new("Updated"), false);
let _ = live.refresh();
live.stop().expect("stop");
}
#[test]
fn test_live_vertical_overflow_ellipsis() {
let buffer = SharedBuffer::new();
let console = Console::builder()
.force_terminal(true)
.width(10)
.height(2)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let options = LiveOptions {
auto_refresh: false,
screen: false,
transient: false,
vertical_overflow: VerticalOverflowMethod::Ellipsis,
..LiveOptions::default()
};
let live = Live::with_options(console, options).renderable(Text::new("a\nb\nc"));
live.start(true).expect("start");
let _ = live.refresh();
live.stop().expect("stop");
let text = buffer.text();
assert!(text.contains("..."), "expected ellipsis, got: {text}");
}
#[test]
fn test_live_vertical_overflow_crop() {
let buffer = SharedBuffer::new();
let console = Console::builder()
.force_terminal(true)
.width(20)
.height(2)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let options = LiveOptions {
auto_refresh: false,
screen: false,
transient: false,
vertical_overflow: VerticalOverflowMethod::Crop,
..LiveOptions::default()
};
let live =
Live::with_options(console, options).renderable(Text::new("line1\nline2\nline3"));
live.start(true).expect("start");
let _ = live.refresh();
live.stop().expect("stop");
let text = buffer.text();
assert!(
!text.contains("..."),
"crop should not add ellipsis: {text}"
);
}
#[test]
fn test_live_vertical_overflow_visible() {
let buffer = SharedBuffer::new();
let console = Console::builder()
.force_terminal(true)
.width(20)
.height(2)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let options = LiveOptions {
auto_refresh: false,
screen: false,
transient: false,
vertical_overflow: VerticalOverflowMethod::Visible,
..LiveOptions::default()
};
let live = Live::with_options(console, options)
.renderable(Text::new("visible1\nvisible2\nvisible3"));
live.start(true).expect("start");
let _ = live.refresh();
live.stop().expect("stop");
let text = buffer.text();
assert!(
!text.contains("..."),
"visible should not add ellipsis: {text}"
);
}
#[test]
fn test_live_writer_proxy() {
let buffer = SharedBuffer::new();
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let live = Live::new(console.clone());
let mut writer = live.stdout_proxy();
let _ = writer.write_all(b"proxy output");
let text = buffer.text();
assert!(
!text.contains("proxy output"),
"LiveWriter should buffer until newline or flush"
);
let _ = writer.write_all(b"\n");
let text = buffer.text();
assert!(text.contains("proxy output"));
}
#[test]
fn test_live_writer_stderr_proxy() {
let buffer = SharedBuffer::new();
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let live = Live::new(console.clone());
let mut writer = live.stderr_proxy();
let _ = writer.write_all(b"stderr content");
let text = buffer.text();
assert!(
!text.contains("stderr content"),
"LiveWriter should buffer until newline or flush"
);
let _ = writer.write_all(b"\n");
let text = buffer.text();
assert!(text.contains("stderr content"));
}
#[test]
fn test_live_writer_decodes_ansi_sgr() {
let buffer = SharedBuffer::new();
let console = Console::builder()
.force_terminal(true)
.color_system(ColorSystem::Standard)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let live = Live::new(console.clone());
let mut writer = live.stdout_proxy();
let _ = writer.write_all(b"\x1b[31mred\x1b[0m\n");
let text = buffer.text();
assert!(text.contains("red"));
assert!(
text.contains("\x1b["),
"expected ANSI output when terminal is forced"
);
}
#[test]
fn test_live_writer_flush() {
let buffer = SharedBuffer::new();
let console = make_test_console(buffer);
let live = Live::new(console);
let mut writer = live.stdout_proxy();
writer.flush().expect("flush should succeed");
}
#[test]
fn test_live_writer_write_returns_length() {
let buffer = SharedBuffer::new();
let console = make_test_console(buffer);
let live = Live::new(console);
let mut writer = live.stdout_proxy();
let data = b"test data";
let written = writer.write(data).expect("write");
assert_eq!(written, data.len());
}
#[test]
fn test_live_writer_crlf_newline_preserves_text() {
let buffer = SharedBuffer::new();
let console = make_test_console(buffer.clone());
let live = Live::new(console);
let mut writer = live.stdout_proxy();
writer
.write_all(b"crlf content\r\n")
.expect("write_all should succeed");
let text = buffer.text();
assert!(text.contains("crlf content"));
}
#[test]
fn test_live_writer_flush_with_trailing_cr_preserves_text() {
let buffer = SharedBuffer::new();
let console = make_test_console(buffer.clone());
let live = Live::new(console);
let mut writer = live.stdout_proxy();
writer
.write_all(b"flush content\r")
.expect("write_all should succeed");
writer.flush().expect("flush should succeed");
let text = buffer.text();
assert!(text.contains("flush content"));
}
#[test]
fn test_live_render_default() {
let render = LiveRender::default();
assert!(render.shape.is_none());
}
#[test]
fn test_live_render_position_cursor_no_shape() {
let render = LiveRender::default();
let controls = render.position_cursor_controls();
assert!(controls.is_empty());
}
#[test]
fn test_live_render_position_cursor_zero_height() {
let render = LiveRender {
shape: Some((10, 0)),
};
let controls = render.position_cursor_controls();
assert!(controls.is_empty());
}
#[test]
fn test_live_render_position_cursor_single_line() {
let render = LiveRender {
shape: Some((10, 1)),
};
let controls = render.position_cursor_controls();
assert!(!controls.is_empty());
assert_eq!(controls.len(), 2);
}
#[test]
fn test_live_render_position_cursor_multiple_lines() {
let render = LiveRender {
shape: Some((10, 3)),
};
let controls = render.position_cursor_controls();
assert_eq!(controls.len(), 6);
}
#[test]
fn test_live_render_restore_cursor_no_shape() {
let render = LiveRender::default();
let controls = render.restore_cursor_controls();
assert!(controls.is_empty());
}
#[test]
fn test_live_render_restore_cursor_zero_height() {
let render = LiveRender {
shape: Some((10, 0)),
};
let controls = render.restore_cursor_controls();
assert!(controls.is_empty());
}
#[test]
fn test_live_render_restore_cursor_with_height() {
let render = LiveRender {
shape: Some((10, 2)),
};
let controls = render.restore_cursor_controls();
assert_eq!(controls.len(), 5);
}
#[test]
fn test_live_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Live>();
}
#[test]
fn test_live_clone() {
let buffer = SharedBuffer::new();
let console = make_test_console(buffer);
let live = Live::new(console);
let _cloned = live.clone();
}
#[test]
fn test_live_concurrent_updates() {
let buffer = SharedBuffer::new();
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let options = LiveOptions {
auto_refresh: false,
screen: false,
transient: false,
..LiveOptions::default()
};
let live = Arc::new(Live::with_options(console, options));
live.start(false).expect("start");
let handles: Vec<_> = (0..4)
.map(|i| {
let live = Arc::clone(&live);
thread::spawn(move || {
for j in 0..10 {
live.update(Text::new(format!("Thread {i} update {j}")), false);
}
})
})
.collect();
for handle in handles {
handle.join().expect("thread should not panic");
}
live.stop().expect("stop");
}
#[test]
fn test_live_auto_refresh_disabled() {
let buffer = SharedBuffer::new();
let console = make_test_console(buffer.clone());
let options = LiveOptions {
auto_refresh: false,
refresh_per_second: 100.0, ..LiveOptions::default()
};
let live = Live::with_options(console, options).renderable(Text::new("Static"));
live.start(true).expect("start");
thread::sleep(Duration::from_millis(50));
let _text_before = buffer.text();
thread::sleep(Duration::from_millis(50));
live.stop().expect("stop");
}
#[test]
fn test_live_auto_refresh_enabled() {
let buffer = SharedBuffer::new();
let console = Console::builder()
.force_terminal(true)
.markup(false)
.file(Box::new(buffer.clone()))
.build()
.shared();
let counter = Arc::new(Mutex::new(0));
let counter_clone = Arc::clone(&counter);
let options = LiveOptions {
auto_refresh: true,
refresh_per_second: 20.0, screen: false,
transient: false,
..LiveOptions::default()
};
let live = Live::with_options(console, options).get_renderable(move || {
let mut c = counter_clone.lock().unwrap();
*c += 1;
Box::new(Text::new(format!("Refresh count: {}", *c)))
});
live.start(true).expect("start");
thread::sleep(Duration::from_millis(200)); live.stop().expect("stop");
let final_count = *counter.lock().unwrap();
assert!(
final_count >= 2,
"expected multiple refreshes, got {final_count}",
);
}
#[test]
fn test_live_empty_renderable() {
let buffer = SharedBuffer::new();
let console = make_test_console(buffer.clone());
let options = LiveOptions {
auto_refresh: false,
screen: false,
transient: false,
..LiveOptions::default()
};
let live = Live::with_options(console, options);
live.start(true).expect("start");
let _ = live.refresh();
live.stop().expect("stop");
}
#[test]
fn test_live_refresh_before_start() {
let buffer = SharedBuffer::new();
let console = make_test_console(buffer);
let live = Live::new(console).renderable(Text::new("Content"));
let _ = live.refresh();
}
#[test]
fn test_live_refresh_after_stop() {
let buffer = SharedBuffer::new();
let console = make_test_console(buffer);
let options = LiveOptions {
auto_refresh: false,
..LiveOptions::default()
};
let live = Live::with_options(console, options).renderable(Text::new("Content"));
live.start(false).expect("start");
live.stop().expect("stop");
let _ = live.refresh();
}
}