pub mod live_render;
pub mod screen;
use std::sync::{Arc, Condvar, Mutex};
use std::thread;
use std::time::Duration;
use arc_swap::ArcSwap;
use crate::console::{Console, Renderable};
use crate::control::Control;
use crate::segment::{ControlCode, ControlType, Segment};
use crate::text::Text;
use self::live_render::{LiveRender, VerticalOverflowMethod};
use self::screen::Screen;
struct LiveContent(Arc<dyn Renderable + Send + Sync>);
struct SharedState {
console: Console,
live_render: LiveRender,
get_renderable: Option<Box<dyn Fn() -> Arc<dyn Renderable + Send + Sync> + Send>>,
screen: bool,
last_segments: Option<Vec<Segment>>,
prev_lines: Vec<Vec<Segment>>,
}
fn emit_control_segments(console: &mut Console, segments: &[Segment]) {
for seg in segments {
if let Some(ref codes) = seg.control {
console.control(&Control::new(codes.clone()));
}
}
}
pub struct Live {
state: Arc<Mutex<SharedState>>,
renderable: Arc<ArcSwap<LiveContent>>,
auto_refresh: bool,
pub refresh_per_second: f64,
pub transient: bool,
vertical_overflow: VerticalOverflowMethod,
started: bool,
paused: bool,
refresh_thread: Option<thread::JoinHandle<()>>,
stop_flag: Arc<(Mutex<bool>, Condvar)>,
}
impl Live {
pub fn new(renderable: impl Renderable + Send + Sync + 'static) -> Self {
let arc: Arc<dyn Renderable + Send + Sync> = Arc::new(renderable);
let live_render = LiveRender::new_arc(arc.clone());
let console = Console::new();
let state = Arc::new(Mutex::new(SharedState {
console,
live_render,
get_renderable: None,
screen: false,
last_segments: None,
prev_lines: Vec::new(),
}));
Live {
state,
renderable: Arc::new(ArcSwap::from_pointee(LiveContent(arc))),
auto_refresh: true,
refresh_per_second: 4.0,
transient: false,
vertical_overflow: VerticalOverflowMethod::Ellipsis,
started: false,
paused: false,
refresh_thread: None,
stop_flag: Arc::new((Mutex::new(false), Condvar::new())),
}
}
#[must_use]
pub fn with_console(self, console: Console) -> Self {
{
let mut s = self.state.lock().unwrap();
s.console = console;
}
self
}
#[must_use]
pub fn with_auto_refresh(mut self, auto_refresh: bool) -> Self {
self.auto_refresh = auto_refresh;
self
}
#[must_use]
pub fn with_refresh_per_second(mut self, rate: f64) -> Self {
assert!(rate > 0.0, "refresh_per_second must be > 0");
self.refresh_per_second = rate;
self
}
#[must_use]
pub fn with_transient(mut self, transient: bool) -> Self {
self.transient = transient;
self
}
#[must_use]
pub fn with_screen(self, screen: bool) -> Self {
{
let mut s = self.state.lock().unwrap();
s.screen = screen;
}
self
}
#[must_use]
pub fn with_vertical_overflow(mut self, overflow: VerticalOverflowMethod) -> Self {
self.vertical_overflow = overflow;
{
let mut s = self.state.lock().unwrap();
s.live_render.vertical_overflow = overflow;
}
self
}
#[must_use]
pub fn with_get_renderable<F>(self, f: F) -> Self
where
F: Fn() -> Arc<dyn Renderable + Send + Sync> + Send + 'static,
{
{
let mut s = self.state.lock().unwrap();
s.get_renderable = Some(Box::new(f));
}
self
}
pub fn console(&self) -> ConsoleRef<'_> {
ConsoleRef {
guard: self.state.lock().unwrap(),
}
}
pub fn console_mut(&self) -> ConsoleRefMut<'_> {
ConsoleRefMut {
guard: self.state.lock().unwrap(),
}
}
pub fn is_started(&self) -> bool {
self.started
}
pub fn is_paused(&self) -> bool {
self.paused
}
pub fn live_render(&self) -> LiveRenderRef<'_> {
LiveRenderRef {
guard: self.state.lock().unwrap(),
}
}
fn live_id(&self) -> usize {
Arc::as_ptr(&self.state) as usize
}
pub fn start(&mut self) {
if self.started {
return;
}
self.started = true;
{
let mut stopped = self.stop_flag.0.lock().unwrap();
*stopped = false;
}
{
let mut s = self.state.lock().unwrap();
s.console.show_cursor(false);
s.console.push_live(self.live_id());
if s.screen {
s.console.set_alt_screen(true);
}
}
self.spawn_refresh_thread();
}
fn spawn_refresh_thread(&mut self) {
if !self.auto_refresh {
return;
}
let flag = Arc::clone(&self.stop_flag);
let state = Arc::clone(&self.state);
let renderable = Arc::clone(&self.renderable);
let vertical_overflow = self.vertical_overflow;
let interval = Duration::from_secs_f64(1.0 / self.refresh_per_second);
let handle = thread::spawn(move || loop {
let (lock, cvar) = &*flag;
let stopped = lock.lock().unwrap();
let result = cvar.wait_timeout(stopped, interval).unwrap();
if *result.0 {
break;
}
drop(result);
Self::do_refresh(&state, &renderable, vertical_overflow);
});
self.refresh_thread = Some(handle);
}
fn stop_refresh_thread(&mut self) {
{
let mut stopped = self.stop_flag.0.lock().unwrap();
*stopped = true;
self.stop_flag.1.notify_all();
}
if let Some(handle) = self.refresh_thread.take() {
let _ = handle.join();
}
}
pub fn stop(&mut self) {
if !self.started {
return;
}
self.started = false;
self.paused = false;
self.stop_refresh_thread();
let mut s = self.state.lock().unwrap();
if self.transient {
let segments = s.live_render.restore_cursor();
emit_control_segments(&mut s.console, &segments);
} else {
if s.live_render.last_render_height() > 0 {
s.console.write_segments(&[Segment::line()]);
}
}
s.console.pop_live();
s.console.show_cursor(true);
if s.screen {
s.console.set_alt_screen(false);
}
}
pub fn pause(&mut self) {
if !self.started || self.paused {
return;
}
self.paused = true;
self.stop_refresh_thread();
let mut s = self.state.lock().unwrap();
let segments = s.live_render.restore_cursor();
emit_control_segments(&mut s.console, &segments);
s.live_render.reset();
s.last_segments = None;
s.prev_lines = Vec::new();
s.console.show_cursor(true);
}
pub fn resume(&mut self) {
if !self.started || !self.paused {
return;
}
self.paused = false;
let current = self.renderable.load().0.clone();
let is_screen = {
let mut s = self.state.lock().unwrap();
s.console.show_cursor(false);
if s.screen {
true
} else {
s.live_render.set_renderable(current);
let opts = s.console.options();
let new_lines = s.live_render.gilt_console_lines(&s.console, &opts);
let render_segments: Vec<Segment> = {
let line_count = new_lines.len();
let mut segs = Vec::new();
for (i, line) in new_lines.iter().enumerate() {
segs.extend(line.iter().cloned());
if i + 1 < line_count {
segs.push(Segment::line());
}
}
segs
};
s.console.write_segments(&render_segments);
s.prev_lines = new_lines;
s.last_segments = Some(render_segments);
false
}
};
{
let mut stopped = self.stop_flag.0.lock().unwrap();
*stopped = false;
}
self.spawn_refresh_thread();
if is_screen {
self.refresh();
}
}
pub fn refresh(&self) {
Self::do_refresh(&self.state, &self.renderable, self.vertical_overflow);
}
fn do_refresh(
state: &Arc<Mutex<SharedState>>,
renderable: &Arc<ArcSwap<LiveContent>>,
vertical_overflow: VerticalOverflowMethod,
) {
let (content, is_screen) = {
let s = state.lock().unwrap();
let content: Arc<dyn Renderable + Send + Sync> = match &s.get_renderable {
Some(f) => f(),
None => renderable.load().0.clone(),
};
(content, s.screen)
};
if is_screen {
let mut s = state.lock().unwrap();
s.live_render.set_renderable(content.clone());
s.live_render.vertical_overflow = vertical_overflow;
s.console.begin_synchronized();
let home_ctrl = crate::control::Control::home();
s.console.control(&home_ctrl);
let screen = Screen::from_arc(content.clone()).with_application_mode(true);
let segments = s.console.render(&screen, None);
s.console.write_segments(&segments);
s.console.end_synchronized();
} else {
let mut s = state.lock().unwrap();
s.live_render.set_renderable(content);
s.live_render.vertical_overflow = vertical_overflow;
let opts = s.console.options();
let new_lines = s.live_render.gilt_console_lines(&s.console, &opts);
let render_segments: Vec<Segment> = {
let line_count = new_lines.len();
let mut segs = Vec::new();
for (i, line) in new_lines.iter().enumerate() {
segs.extend(line.iter().cloned());
if i + 1 < line_count {
segs.push(Segment::line());
}
}
segs
};
if s.last_segments.as_deref() == Some(render_segments.as_slice()) {
return;
}
s.console.begin_synchronized();
let prev_lines = std::mem::take(&mut s.prev_lines);
let prev_height = prev_lines.len();
let new_height = new_lines.len();
if prev_height == 0 {
emit_control_segments(
&mut s.console,
&[Segment::new(
"",
None,
Some(vec![
ControlCode::Simple(ControlType::CarriageReturn),
ControlCode::WithParam(ControlType::EraseInLine, 2),
]),
)],
);
s.console.write_segments(&render_segments);
} else {
let mut codes: Vec<ControlCode> = Vec::new();
codes.push(ControlCode::Simple(ControlType::CarriageReturn));
for _ in 0..prev_height.saturating_sub(1) {
codes.push(ControlCode::WithParam(ControlType::CursorUp, 1));
}
emit_control_segments(&mut s.console, &[Segment::new("", None, Some(codes))]);
for (i, new_line) in new_lines.iter().enumerate().take(new_height) {
let prev_line = prev_lines.get(i);
let line_changed = prev_line != Some(new_line);
if line_changed {
let erase = Segment::new(
"",
None,
Some(vec![
ControlCode::Simple(ControlType::CarriageReturn),
ControlCode::WithParam(ControlType::EraseInLine, 2),
]),
);
emit_control_segments(&mut s.console, &[erase]);
s.console.write_segments(new_line);
}
if i + 1 < new_height {
let down = Segment::new(
"",
None,
Some(vec![ControlCode::WithParam(ControlType::CursorDown, 1)]),
);
emit_control_segments(&mut s.console, &[down]);
}
}
if prev_height > new_height {
for _ in new_height..prev_height {
let erase_old = Segment::new(
"",
None,
Some(vec![
ControlCode::WithParam(ControlType::CursorDown, 1),
ControlCode::Simple(ControlType::CarriageReturn),
ControlCode::WithParam(ControlType::EraseInLine, 2),
]),
);
emit_control_segments(&mut s.console, &[erase_old]);
}
let lines_to_go_up = (prev_height - new_height) as i32;
if lines_to_go_up > 0 {
let go_up = Segment::new(
"",
None,
Some(vec![ControlCode::WithParam(
ControlType::CursorUp,
lines_to_go_up,
)]),
);
emit_control_segments(&mut s.console, &[go_up]);
}
}
}
s.console.end_synchronized();
s.last_segments = Some(render_segments);
s.prev_lines = new_lines;
}
}
pub fn update_renderable(
&self,
renderable: impl Renderable + Send + Sync + 'static,
refresh: bool,
) {
let arc: Arc<dyn Renderable + Send + Sync> = Arc::new(renderable);
self.renderable.store(Arc::new(LiveContent(arc)));
if refresh {
self.refresh();
}
}
pub fn update(&self, renderable: impl Renderable + Send + Sync + 'static, refresh: bool) {
self.update_renderable(renderable, refresh);
}
pub fn set(&self, renderable: impl Renderable + Send + Sync + 'static) {
self.update_renderable(renderable, true);
}
pub fn from_renderable<R: Renderable + Send + Sync + 'static>(renderable: R) -> Self {
Self::new(renderable)
}
pub fn set_renderable_widget<R: Renderable + Send + Sync + 'static>(&self, renderable: R) {
self.set(renderable);
}
pub fn print_above(&self, above: impl Renderable + Send + Sync + 'static) {
let current = self.renderable.load().0.clone();
let mut s = self.state.lock().unwrap();
if s.screen {
return;
}
let restore = s.live_render.restore_cursor();
emit_control_segments(&mut s.console, &restore);
s.console.print(&above);
s.console.write_segments(&[Segment::line()]);
s.live_render.set_renderable(current);
let opts = s.console.options();
let render_segments = s.live_render.gilt_console(&s.console, &opts);
s.console.write_segments(&render_segments);
s.last_segments = None;
s.prev_lines = Vec::new();
}
pub fn run(initial: impl Renderable + Send + Sync + 'static) -> Self {
let mut live = Self::new(initial);
live.start();
live
}
pub fn current_renderable(&self) -> Arc<dyn Renderable + Send + Sync> {
self.renderable.load().0.clone()
}
pub fn render_to_text(&self) -> Text {
let content = self.current_renderable();
let s = self.state.lock().unwrap();
let opts = s.console.options();
let lines = s
.console
.render_lines(content.as_ref(), Some(&opts), None, false, false);
let mut result = String::new();
let line_count = lines.len();
for (i, line) in lines.into_iter().enumerate() {
for seg in &line {
if !seg.is_control() {
result.push_str(&seg.text);
}
}
if i + 1 < line_count {
result.push('\n');
}
}
Text::new(&result, crate::style::Style::null())
}
pub fn renderable(&self) -> Text {
self.render_to_text()
}
}
impl Drop for Live {
fn drop(&mut self) {
self.stop();
}
}
pub struct ConsoleRef<'a> {
guard: std::sync::MutexGuard<'a, SharedState>,
}
impl std::ops::Deref for ConsoleRef<'_> {
type Target = Console;
fn deref(&self) -> &Console {
&self.guard.console
}
}
pub struct ConsoleRefMut<'a> {
guard: std::sync::MutexGuard<'a, SharedState>,
}
impl std::ops::Deref for ConsoleRefMut<'_> {
type Target = Console;
fn deref(&self) -> &Console {
&self.guard.console
}
}
impl std::ops::DerefMut for ConsoleRefMut<'_> {
fn deref_mut(&mut self) -> &mut Console {
&mut self.guard.console
}
}
pub struct LiveRenderRef<'a> {
guard: std::sync::MutexGuard<'a, SharedState>,
}
impl std::ops::Deref for LiveRenderRef<'_> {
type Target = LiveRender;
fn deref(&self) -> &LiveRender {
&self.guard.live_render
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::Style;
use std::sync::atomic::{AtomicUsize, Ordering};
fn test_console() -> Console {
Console::builder()
.width(80)
.height(25)
.quiet(true)
.markup(false)
.no_color(true)
.force_terminal(true)
.build()
}
#[test]
fn test_default_construction() {
let live = Live::new(Text::new("hello", Style::null()));
assert!(!live.started);
assert!(live.auto_refresh);
assert!((live.refresh_per_second - 4.0).abs() < f64::EPSILON);
assert!(live.refresh_thread.is_none());
assert!(!live.transient);
assert_eq!(live.vertical_overflow, VerticalOverflowMethod::Ellipsis);
}
#[test]
fn test_construction_stores_renderable() {
let live = Live::new(Text::new("Hello", Style::null()));
let text = live.renderable();
assert!(text.plain().contains("Hello"));
}
#[test]
fn test_with_auto_refresh() {
let live = Live::new(Text::empty()).with_auto_refresh(false);
assert!(!live.auto_refresh);
}
#[test]
fn test_with_refresh_per_second() {
let live = Live::new(Text::empty()).with_refresh_per_second(10.0);
assert!((live.refresh_per_second - 10.0).abs() < f64::EPSILON);
}
#[test]
#[should_panic(expected = "refresh_per_second must be > 0")]
fn test_with_refresh_per_second_zero() {
let _ = Live::new(Text::empty()).with_refresh_per_second(0.0);
}
#[test]
#[should_panic(expected = "refresh_per_second must be > 0")]
fn test_with_refresh_per_second_negative() {
let _ = Live::new(Text::empty()).with_refresh_per_second(-1.0);
}
#[test]
fn test_with_transient() {
let live = Live::new(Text::empty()).with_transient(true);
assert!(live.transient);
}
#[test]
fn test_with_screen() {
let live = Live::new(Text::empty()).with_screen(true);
let s = live.state.lock().unwrap();
assert!(s.screen);
}
#[test]
fn test_with_vertical_overflow() {
let live = Live::new(Text::empty()).with_vertical_overflow(VerticalOverflowMethod::Crop);
assert_eq!(live.vertical_overflow, VerticalOverflowMethod::Crop);
let s = live.state.lock().unwrap();
assert_eq!(
s.live_render.vertical_overflow,
VerticalOverflowMethod::Crop
);
}
#[test]
fn test_with_console() {
let console = test_console();
let live = Live::new(Text::empty()).with_console(console);
assert_eq!(live.console().width(), 80);
}
#[test]
fn test_with_get_renderable() {
let live = Live::new(Text::empty()).with_get_renderable(|| {
Arc::new(Text::new("dynamic", Style::null())) as Arc<dyn Renderable + Send + Sync>
});
let s = live.state.lock().unwrap();
assert!(s.get_renderable.is_some());
}
#[test]
fn test_start_stop() {
let mut live = Live::new(Text::new("test", Style::null()))
.with_console(test_console())
.with_auto_refresh(false);
assert!(!live.is_started());
live.start();
assert!(live.is_started());
live.stop();
assert!(!live.is_started());
}
#[test]
fn test_double_start_is_noop() {
let mut live = Live::new(Text::empty())
.with_console(test_console())
.with_auto_refresh(false);
live.start();
assert!(live.is_started());
live.start(); assert!(live.is_started());
live.stop();
}
#[test]
fn test_double_stop_is_noop() {
let mut live = Live::new(Text::empty())
.with_console(test_console())
.with_auto_refresh(false);
live.start();
live.stop();
assert!(!live.is_started());
live.stop(); assert!(!live.is_started());
}
#[test]
fn test_stop_without_start_is_noop() {
let mut live = Live::new(Text::empty())
.with_console(test_console())
.with_auto_refresh(false);
live.stop(); assert!(!live.is_started());
}
#[test]
fn test_update_renderable_changes_content() {
let live = Live::new(Text::new("initial", Style::null()))
.with_console(test_console())
.with_auto_refresh(false);
live.update_renderable(Text::new("updated", Style::null()), false);
let txt = live.renderable();
assert!(txt.plain().contains("updated"));
}
#[test]
fn test_update_alias() {
let live = Live::new(Text::new("initial", Style::null()))
.with_console(test_console())
.with_auto_refresh(false);
live.update(Text::new("via_update", Style::null()), false);
let txt = live.renderable();
assert!(txt.plain().contains("via_update"));
}
#[test]
fn test_update_with_refresh() {
let mut live = Live::new(Text::new("initial", Style::null()))
.with_console(test_console())
.with_auto_refresh(false);
live.start();
live.update_renderable(Text::new("refreshed", Style::null()), true);
let txt = live.renderable();
assert!(txt.plain().contains("refreshed"));
live.stop();
}
#[test]
fn test_renderable_returns_current() {
let live = Live::new(Text::new("hello", Style::null()))
.with_console(test_console())
.with_auto_refresh(false);
let txt = live.renderable();
assert!(txt.plain().contains("hello"));
}
#[test]
fn test_update_also_updates_live_render() {
let live = Live::new(Text::new("old", Style::null()))
.with_console(test_console())
.with_auto_refresh(false);
live.update_renderable(Text::new("new", Style::null()), false);
let arc = live.current_renderable();
let txt = live.render_to_text();
assert!(txt.plain().contains("new"));
live.refresh();
let _ = arc; }
#[test]
fn test_auto_refresh_thread_starts_and_stops() {
let mut live = Live::new(Text::new("auto", Style::null()))
.with_console(test_console())
.with_auto_refresh(true)
.with_refresh_per_second(20.0);
live.start();
assert!(live.refresh_thread.is_some());
thread::sleep(Duration::from_millis(100));
live.stop();
assert!(live.refresh_thread.is_none());
}
#[test]
fn test_no_refresh_thread_when_disabled() {
let mut live = Live::new(Text::empty())
.with_console(test_console())
.with_auto_refresh(false);
live.start();
assert!(live.refresh_thread.is_none());
live.stop();
}
#[test]
fn test_refresh_thread_calls_refresh() {
let counter = Arc::new(AtomicUsize::new(0));
let counter_clone = Arc::clone(&counter);
let mut live = Live::new(Text::empty())
.with_console(test_console())
.with_auto_refresh(true)
.with_refresh_per_second(100.0)
.with_get_renderable(move || {
counter_clone.fetch_add(1, Ordering::SeqCst);
Arc::new(Text::new("tick", Style::null())) as Arc<dyn Renderable + Send + Sync>
});
live.start();
thread::sleep(Duration::from_millis(150));
live.stop();
let count = counter.load(Ordering::SeqCst);
assert!(
count >= 2,
"expected at least 2 refresh calls, got {}",
count
);
}
#[test]
fn test_transient_mode_flag() {
let live = Live::new(Text::empty()).with_transient(true);
assert!(live.transient);
}
#[test]
fn test_transient_stop_does_not_panic() {
let mut live = Live::new(Text::new("gone", Style::null()))
.with_console(test_console())
.with_transient(true)
.with_auto_refresh(false);
live.start();
live.refresh();
live.stop();
}
#[test]
fn test_screen_mode_flag() {
let live = Live::new(Text::empty()).with_screen(true);
let s = live.state.lock().unwrap();
assert!(s.screen);
}
#[test]
fn test_screen_mode_start_stop() {
let mut live = Live::new(Text::new("screen", Style::null()))
.with_console(test_console())
.with_screen(true)
.with_auto_refresh(false);
live.start();
assert!(live.is_started());
live.stop();
assert!(!live.is_started());
}
#[test]
fn test_drop_calls_stop() {
let stop_flag;
{
let mut live = Live::new(Text::empty())
.with_console(test_console())
.with_auto_refresh(false);
live.start();
assert!(live.is_started());
stop_flag = Arc::clone(&live.stop_flag);
}
let stopped = stop_flag.0.lock().unwrap();
assert!(*stopped, "Drop should have called stop()");
}
#[test]
fn test_drop_with_auto_refresh_cleans_up() {
let stop_flag;
{
let mut live = Live::new(Text::empty())
.with_console(test_console())
.with_auto_refresh(true)
.with_refresh_per_second(20.0);
live.start();
stop_flag = Arc::clone(&live.stop_flag);
}
let stopped = stop_flag.0.lock().unwrap();
assert!(*stopped, "Drop should have signalled the stop flag");
}
#[test]
fn test_drop_without_start_does_not_panic() {
let _live = Live::new(Text::empty())
.with_console(test_console())
.with_auto_refresh(true);
}
#[test]
fn test_manual_refresh() {
let mut live = Live::new(Text::new("manual", Style::null()))
.with_console(test_console())
.with_auto_refresh(false);
live.start();
live.refresh();
live.refresh();
live.stop();
}
#[test]
fn test_get_renderable_callback_used_on_refresh() {
let mut live = Live::new(Text::empty())
.with_console(test_console())
.with_auto_refresh(false)
.with_get_renderable(|| {
Arc::new(Text::new("from_callback", Style::null()))
as Arc<dyn Renderable + Send + Sync>
});
live.start();
live.refresh();
live.stop();
}
#[test]
fn test_full_builder_chain() {
let live = Live::new(Text::new("test", Style::null()))
.with_console(test_console())
.with_auto_refresh(true)
.with_refresh_per_second(10.0)
.with_transient(false)
.with_screen(false)
.with_vertical_overflow(VerticalOverflowMethod::Visible);
assert!(live.auto_refresh);
assert!((live.refresh_per_second - 10.0).abs() < f64::EPSILON);
assert!(!live.transient);
assert_eq!(live.vertical_overflow, VerticalOverflowMethod::Visible);
}
#[test]
fn test_start_stop_start_again() {
let mut live = Live::new(Text::empty())
.with_console(test_console())
.with_auto_refresh(false);
live.start();
live.stop();
live.start();
assert!(live.is_started());
live.stop();
assert!(!live.is_started());
}
#[test]
fn test_update_before_start() {
let live = Live::new(Text::empty())
.with_console(test_console())
.with_auto_refresh(false);
live.update_renderable(Text::new("before start", Style::null()), false);
let txt = live.renderable();
assert!(txt.plain().contains("before start"));
}
#[test]
fn test_refresh_before_start() {
let live = Live::new(Text::new("pre-start", Style::null()))
.with_console(test_console())
.with_auto_refresh(false);
live.refresh();
}
#[test]
fn test_auto_refresh_restart() {
let mut live = Live::new(Text::empty())
.with_console(test_console())
.with_auto_refresh(true)
.with_refresh_per_second(20.0);
live.start();
assert!(live.refresh_thread.is_some());
live.stop();
assert!(live.refresh_thread.is_none());
live.start();
assert!(live.refresh_thread.is_some());
live.stop();
assert!(live.refresh_thread.is_none());
}
#[test]
fn test_vertical_overflow_visible() {
let live = Live::new(Text::empty()).with_vertical_overflow(VerticalOverflowMethod::Visible);
assert_eq!(live.vertical_overflow, VerticalOverflowMethod::Visible);
}
#[test]
fn test_vertical_overflow_ellipsis_default() {
let live = Live::new(Text::empty());
assert_eq!(live.vertical_overflow, VerticalOverflowMethod::Ellipsis);
}
#[test]
fn live_stop_with_no_render_emits_no_newline() {
let console = Console::builder()
.width(80)
.height(25)
.quiet(false)
.markup(false)
.no_color(true)
.force_terminal(true)
.build();
let mut live = Live::new(Text::new("hello", Style::null()))
.with_console(console)
.with_auto_refresh(false);
live.state.lock().unwrap().console.begin_capture();
live.start();
live.stop();
let captured = live.state.lock().unwrap().console.end_capture();
assert!(
!captured.contains('\n'),
"expected no trailing newline when nothing was rendered, got: {:?}",
captured
);
}
#[test]
fn live_stop_after_render_emits_newline() {
let console = Console::builder()
.width(80)
.height(25)
.quiet(false)
.markup(false)
.no_color(true)
.force_terminal(true)
.build();
let mut live = Live::new(Text::new("progress", Style::null()))
.with_console(console)
.with_auto_refresh(false);
live.state.lock().unwrap().console.begin_capture();
live.start();
live.refresh();
live.stop();
let captured = live.state.lock().unwrap().console.end_capture();
assert!(
captured.contains('\n'),
"expected a trailing newline after rendering, got: {:?}",
captured
);
}
#[test]
fn test_console_accessor() {
let live = Live::new(Text::new("test", Style::null()))
.with_console(Console::builder().width(120).build());
assert_eq!(live.console().width(), 120);
}
#[test]
fn test_console_mut_accessor() {
let live = Live::new(Text::new("test", Style::null())).with_console(test_console());
let _console = live.console_mut();
}
#[test]
fn print_above_screen_mode_is_noop() {
let mut live = Live::new(Text::new("live", Style::null()))
.with_console(test_console())
.with_screen(true)
.with_auto_refresh(false);
live.start();
live.print_above(Text::new("above", Style::null())); live.stop();
}
#[test]
fn refresh_wraps_emit_in_synchronized_output() {
let console = Console::builder()
.width(80)
.height(25)
.quiet(false)
.markup(false)
.no_color(true)
.force_terminal(true)
.build();
let mut live = Live::new(Text::new("frame content", Style::null()))
.with_console(console)
.with_auto_refresh(false);
live.state.lock().unwrap().console.begin_capture();
live.start();
live.refresh();
let captured = live.state.lock().unwrap().console.end_capture();
assert!(
captured.contains("\x1b[?2026h"),
"frame should open synchronized output (CSI ?2026h): {captured:?}"
);
assert!(
captured.contains("\x1b[?2026l"),
"frame should close synchronized output (CSI ?2026l): {captured:?}"
);
let begin = captured.find("\x1b[?2026h").unwrap();
let end = captured.rfind("\x1b[?2026l").unwrap();
assert!(begin < end, "begin-sync must precede end-sync");
assert!(captured.contains("frame content"));
}
#[test]
fn print_above_emits_content_and_redraws_live_region() {
let console = Console::builder()
.width(80)
.height(25)
.quiet(false)
.markup(false)
.no_color(true)
.force_terminal(true)
.build();
let mut live = Live::new(Text::new("live_content", Style::null()))
.with_console(console)
.with_auto_refresh(false);
live.state.lock().unwrap().console.begin_capture();
live.start();
live.refresh();
live.print_above(Text::new("above_content", Style::null()));
live.stop();
let captured = live.state.lock().unwrap().console.end_capture();
assert!(
captured.contains("above_content"),
"above content missing; got: {:?}",
captured
);
assert!(
captured.contains("live_content"),
"live content missing after print_above; got: {:?}",
captured
);
}
#[test]
fn frame_skip_identical_refreshes_skip_second_write() {
let console = Console::builder()
.width(80)
.height(25)
.quiet(false)
.markup(false)
.no_color(true)
.force_terminal(true)
.build();
let mut live = Live::new(Text::new("same_content", Style::null()))
.with_console(console)
.with_auto_refresh(false);
live.state.lock().unwrap().console.begin_capture();
live.start();
live.refresh();
let after_first = live.state.lock().unwrap().console.end_capture();
live.state.lock().unwrap().console.begin_capture();
live.refresh();
let after_second = live.state.lock().unwrap().console.end_capture();
live.stop();
assert!(
!after_first.is_empty(),
"first refresh should produce output"
);
assert!(
after_second.is_empty(),
"second identical refresh should be skipped (no I/O), got: {:?}",
after_second
);
}
#[test]
fn frame_skip_different_content_is_emitted() {
let console = Console::builder()
.width(80)
.height(25)
.quiet(false)
.markup(false)
.no_color(true)
.force_terminal(true)
.build();
let mut live = Live::new(Text::new("first", Style::null()))
.with_console(console)
.with_auto_refresh(false);
live.start();
live.refresh();
live.state.lock().unwrap().console.begin_capture();
live.update_renderable(Text::new("second", Style::null()), false);
live.refresh();
let captured = live.state.lock().unwrap().console.end_capture();
live.stop();
assert!(
captured.contains("second"),
"changed content should have been emitted, got: {:?}",
captured
);
}
#[test]
fn test_from_renderable_stores_directly() {
let text = Text::new("from_renderable_test", Style::null());
let live = Live::from_renderable(text).with_console(test_console());
let txt = live.renderable();
assert!(txt.plain().contains("from_renderable_test"));
}
#[test]
fn test_current_renderable_is_arc() {
let live = Live::new(Text::new("arc_test", Style::null()))
.with_console(test_console())
.with_auto_refresh(false);
let arc = live.current_renderable();
let _arc2 = arc.clone();
}
#[test]
fn test_render_to_text_uses_live_console() {
let live = Live::new(Text::new("render_to_text_test", Style::null()))
.with_console(test_console())
.with_auto_refresh(false);
let txt = live.render_to_text();
assert!(txt.plain().contains("render_to_text_test"));
}
#[test]
fn line_diff_unchanged_lines_not_rewritten() {
let console = Console::builder()
.width(80)
.height(25)
.quiet(false)
.markup(false)
.no_color(true)
.force_terminal(true)
.build();
let mut live = Live::new(Text::new("alpha\nbeta\ngamma", Style::null()))
.with_console(console)
.with_auto_refresh(false);
live.state.lock().unwrap().console.begin_capture();
live.start();
live.refresh();
let _ = live.state.lock().unwrap().console.end_capture();
live.state.lock().unwrap().console.begin_capture();
live.update_renderable(
Text::new("alpha\nBETA_CHANGED\ngamma", Style::null()),
false,
);
live.refresh();
let second_capture = live.state.lock().unwrap().console.end_capture();
live.stop();
assert!(
second_capture.contains("BETA_CHANGED"),
"new middle-line text must be emitted, got: {:?}",
second_capture
);
assert!(
!second_capture.contains("alpha"),
"unchanged first line 'alpha' must NOT be rewritten in second capture, \
got: {:?}",
second_capture
);
assert!(
!second_capture.contains("gamma"),
"unchanged third line 'gamma' must NOT be rewritten in second capture, \
got: {:?}",
second_capture
);
}
#[test]
fn line_diff_final_content_correct() {
let console = Console::builder()
.width(80)
.height(25)
.quiet(false)
.markup(false)
.no_color(true)
.force_terminal(true)
.build();
let mut live = Live::new(Text::new("line1\nline2\nline3", Style::null()))
.with_console(console)
.with_auto_refresh(false);
live.start();
live.refresh();
live.update_renderable(
Text::new("line1\nLINE2_UPDATED\nline3", Style::null()),
false,
);
live.state.lock().unwrap().console.begin_capture();
live.refresh();
let captured = live.state.lock().unwrap().console.end_capture();
live.stop();
assert!(
captured.contains("LINE2_UPDATED"),
"updated line must appear in output, got: {:?}",
captured
);
}
#[test]
fn test_live_renders_with_live_console_width() {
let console = Console::builder()
.width(120)
.height(25)
.quiet(true)
.markup(false)
.no_color(true)
.force_terminal(true)
.build();
let long_line = "A".repeat(100);
let live = Live::new(Text::new(&long_line, Style::null()))
.with_console(console)
.with_auto_refresh(false);
let rendered = live.render_to_text();
let plain = rendered.plain();
assert!(
plain.contains(&long_line),
"expected 100-char line to fit without wrapping at 120 cols, got: {:?}",
plain
);
}
fn capture_console() -> Console {
Console::builder()
.width(80)
.height(25)
.quiet(false)
.markup(false)
.no_color(true)
.force_terminal(true)
.build()
}
#[test]
fn pause_resume_footer_handoff_no_stale_scrollback() {
let mut outer = Live::new(Text::new("FOOTER_L1\nFOOTER_L2\nFOOTER_L3", Style::null()))
.with_console(capture_console())
.with_auto_refresh(false);
outer.start();
outer.refresh();
assert!(outer.is_started());
assert!(!outer.is_paused());
outer.state.lock().unwrap().console.begin_capture();
outer.pause();
let paused = outer.state.lock().unwrap().console.end_capture();
assert!(outer.is_paused(), "pause() should mark the display paused");
assert!(
outer.is_started(),
"pause() must preserve the started state so resume() can restore it"
);
assert!(
paused.contains("\x1b[2K"),
"pause must erase the rendered footer lines, got: {paused:?}"
);
assert!(
paused.contains("\x1b[?25h"),
"pause must show the cursor, got: {paused:?}"
);
assert!(
!paused.contains("FOOTER_L1"),
"pause must not re-print footer text, got: {paused:?}"
);
assert!(
!paused.contains('\n'),
"pause must not emit a newline into the scrollback, got: {paused:?}"
);
outer.state.lock().unwrap().console.begin_capture();
outer.resume();
let resumed = outer.state.lock().unwrap().console.end_capture();
assert!(!outer.is_paused(), "resume() should clear the paused state");
assert!(outer.is_started());
assert!(
resumed.contains("FOOTER_L1") && resumed.contains("FOOTER_L3"),
"resume must redraw the full footer, got: {resumed:?}"
);
assert!(
resumed.contains("\x1b[?25l"),
"resume must hide the cursor again, got: {resumed:?}"
);
assert!(
!resumed.contains("\x1b[1A"),
"resume must not move the cursor up over content above, got: {resumed:?}"
);
outer.stop();
}
#[test]
fn pause_then_stop_emits_no_trailing_newline() {
let mut live = Live::new(Text::new("footer\npanel", Style::null()))
.with_console(capture_console())
.with_auto_refresh(false);
live.start();
live.refresh();
live.pause();
live.state.lock().unwrap().console.begin_capture();
live.stop();
let captured = live.state.lock().unwrap().console.end_capture();
assert!(
!captured.contains('\n'),
"stop() after pause() must not emit a trailing newline, got: {captured:?}"
);
}
#[test]
fn pause_without_start_is_noop() {
let mut live = Live::new(Text::empty())
.with_console(test_console())
.with_auto_refresh(false);
live.pause();
assert!(!live.is_paused());
assert!(!live.is_started());
}
#[test]
fn double_pause_is_noop() {
let mut live = Live::new(Text::new("x", Style::null()))
.with_console(test_console())
.with_auto_refresh(false);
live.start();
live.refresh();
live.pause();
assert!(live.is_paused());
live.pause(); assert!(live.is_paused());
live.stop();
}
#[test]
fn resume_without_pause_is_noop() {
let mut live = Live::new(Text::new("x", Style::null()))
.with_console(test_console())
.with_auto_refresh(false);
live.start();
live.refresh();
live.resume(); assert!(!live.is_paused());
assert!(live.is_started());
live.stop();
}
#[test]
fn resume_without_start_is_noop() {
let mut live = Live::new(Text::empty())
.with_console(test_console())
.with_auto_refresh(false);
live.resume();
assert!(!live.is_paused());
assert!(!live.is_started());
}
#[test]
fn pause_resume_stops_and_restarts_refresh_thread() {
let mut live = Live::new(Text::new("tick", Style::null()))
.with_console(test_console())
.with_auto_refresh(true)
.with_refresh_per_second(50.0);
live.start();
assert!(live.refresh_thread.is_some());
live.pause();
assert!(
live.refresh_thread.is_none(),
"pause must stop the background refresh thread"
);
assert!(live.is_paused());
live.resume();
assert!(
live.refresh_thread.is_some(),
"resume must restart the background refresh thread"
);
assert!(!live.is_paused());
live.stop();
assert!(live.refresh_thread.is_none());
}
#[test]
fn new_live_is_not_paused() {
let live = Live::new(Text::empty());
assert!(!live.is_paused());
}
#[test]
fn live_stack_depth_start_stop() {
let mut live = Live::new(Text::new("depth", Style::null()))
.with_console(test_console())
.with_auto_refresh(false);
assert_eq!(
live.state.lock().unwrap().console.live_depth(),
0,
"depth before start must be 0"
);
live.start();
assert_eq!(
live.state.lock().unwrap().console.live_depth(),
1,
"depth after start must be 1"
);
live.stop();
assert_eq!(
live.state.lock().unwrap().console.live_depth(),
0,
"depth after stop must be 0"
);
}
#[test]
fn live_stack_depth_pause_resume_invariant() {
let mut live = Live::new(Text::new("pause_depth", Style::null()))
.with_console(test_console())
.with_auto_refresh(false);
live.start();
assert_eq!(
live.state.lock().unwrap().console.live_depth(),
1,
"depth after start must be 1"
);
live.refresh(); live.pause();
assert_eq!(
live.state.lock().unwrap().console.live_depth(),
1,
"depth after pause must still be 1 (pause must NOT pop)"
);
live.resume();
assert_eq!(
live.state.lock().unwrap().console.live_depth(),
1,
"depth after resume must still be 1 (resume must NOT push)"
);
live.stop();
assert_eq!(
live.state.lock().unwrap().console.live_depth(),
0,
"depth after stop must be 0"
);
}
fn split_layout() -> crate::layout::Layout {
use crate::layout::Layout;
use crate::panel::Panel;
let header = Layout::new(None, Some("header".into()), Some(3), None, None, None)
.with_renderable(Panel::new(Text::new("HEADER", Style::null())));
let sidebar = Layout::new(None, Some("sidebar".into()), Some(12), None, None, None)
.with_renderable(Panel::new(Text::new("side", Style::null())));
let main = Layout::new(None, Some("main".into()), None, None, Some(1), None)
.with_renderable(Panel::new(Text::new("MAIN", Style::null())));
let mut body = Layout::new(None, Some("body".into()), None, None, Some(1), None);
body.split_row(vec![sidebar, main]);
let footer = Layout::new(None, Some("footer".into()), Some(3), None, None, None)
.with_renderable(Panel::new(Text::new("FOOTER", Style::null())));
let mut root = Layout::new(None, Some("root".into()), None, None, None, None);
root.split_column(vec![header, body, footer]);
root
}
fn has_bare_lf(s: &str) -> bool {
let b = s.as_bytes();
for (i, &c) in b.iter().enumerate() {
if c == b'\n' {
let prev_cr = i > 0 && b[i - 1] == b'\r';
let next_cr = i + 1 < b.len() && b[i + 1] == b'\r';
if !prev_cr && !next_cr {
return true;
}
}
}
false
}
fn capture_screen_frame() -> String {
let console = Console::builder()
.width(40)
.height(12)
.markup(false)
.no_color(true)
.force_terminal(true)
.build();
let live = Live::new(split_layout())
.with_console(console)
.with_screen(true)
.with_auto_refresh(false);
{
let mut s = live.state.lock().unwrap();
s.console.begin_capture();
s.console.set_alt_screen(true);
}
live.refresh();
let mut s = live.state.lock().unwrap();
s.console.end_capture()
}
#[test]
fn screen_mode_split_layout_emits_no_bare_lf() {
let frame = capture_screen_frame();
assert!(
frame.contains('\n'),
"screen frame should contain line separators; got {frame:?}"
);
assert!(
!has_bare_lf(&frame),
"screen frame must not contain a bare LF (raw-mode collapse); got {frame:?}"
);
assert!(
frame.contains("\n\r"),
"screen frame should use CR-bearing line separators; got {frame:?}"
);
}
#[test]
fn screen_mode_split_layout_keeps_row_geometry() {
let frame = capture_screen_frame();
let plain: String = {
let mut out = String::new();
let bytes: Vec<char> = frame.chars().collect();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == '\x1b' {
i += 1;
if i < bytes.len() && bytes[i] == '[' {
i += 1;
while i < bytes.len() && !bytes[i].is_ascii_alphabetic() {
i += 1;
}
i += 1; }
} else if bytes[i] == '\r' {
i += 1;
} else {
out.push(bytes[i]);
i += 1;
}
}
out
};
assert!(
plain.contains("side"),
"sidebar region must render its content; got {plain:?}"
);
assert!(
plain.contains("MAIN"),
"main region must be non-empty; got {plain:?}"
);
assert!(
plain.lines().any(|l| l.contains("\u{2502}\u{2502}")),
"row-split regions must sit side by side (adjacent border junction); got {plain:?}"
);
}
}