use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::Result;
use crate::capture::CastRecording;
use crate::core::component::Component;
use crate::core::element::Element;
use crate::mockup::Mockup;
use crate::style::Rect;
use crate::test_backend::TestBackend;
const DEFAULT_FPS: u16 = 30;
const DEFAULT_VIEWPORT: (u16, u16) = (100, 30);
const DEFAULT_KEY_DELAY: Duration = Duration::from_millis(400);
const DEFAULT_SETTLE: Duration = Duration::from_millis(1200);
const MAX_TICK: Duration = Duration::from_millis(50);
pub struct Recording<C: Component> {
title: String,
component: C,
viewport: (u16, u16),
fps: u16,
key_script: Option<String>,
action_script: Option<String>,
key_delay: Duration,
settle: Duration,
quiet: bool,
#[cfg(feature = "ui-snapshot-png")]
png_options: Option<crate::capture::PngOptions>,
}
impl<F> Recording<Mockup<F>>
where
F: Fn() -> Element + 'static,
{
pub fn view(title: impl Into<String>, view: F) -> Self {
Self::component(title, Mockup::new(view))
}
}
impl<C: Component> Recording<C>
where
C::Properties: Default,
{
pub fn component(title: impl Into<String>, component: C) -> Self {
Self {
title: title.into(),
component,
viewport: DEFAULT_VIEWPORT,
fps: DEFAULT_FPS,
key_script: None,
action_script: None,
key_delay: DEFAULT_KEY_DELAY,
settle: DEFAULT_SETTLE,
quiet: false,
#[cfg(feature = "ui-snapshot-png")]
png_options: None,
}
}
#[must_use]
pub fn viewport(mut self, w: u16, h: u16) -> Self {
self.viewport = (w.max(1), h.max(1));
self
}
#[must_use]
pub fn fps(mut self, fps: u16) -> Self {
self.fps = fps.max(1);
self
}
#[must_use]
pub fn keys(mut self, script: impl AsRef<str>) -> Self {
self.key_script = Some(script.as_ref().to_owned());
self
}
#[must_use]
pub fn script(mut self, script: impl AsRef<str>) -> Self {
self.action_script = Some(script.as_ref().to_owned());
self
}
#[must_use]
pub fn key_delay(mut self, delay: Duration) -> Self {
self.key_delay = delay;
self
}
#[must_use]
pub fn settle(mut self, settle: Duration) -> Self {
self.settle = settle;
self
}
#[must_use]
pub fn quiet(mut self, quiet: bool) -> Self {
self.quiet = quiet;
self
}
#[cfg(feature = "ui-snapshot-png")]
#[must_use]
pub fn png_options(mut self, options: crate::capture::PngOptions) -> Self {
self.png_options = Some(options);
self
}
fn play(
self,
mut sink: impl FnMut(f64, &crate::capture::CapturedFrame) -> Result<()>,
) -> Result<()> {
let Self {
title: _,
component,
viewport,
fps,
key_script,
action_script,
key_delay,
settle,
quiet: _,
#[cfg(feature = "ui-snapshot-png")]
png_options: _,
} = self;
let actions = resolve_actions(action_script.as_deref(), key_script.as_deref())?;
let (w, h) = viewport;
let mut backend = TestBackend::new(component);
backend.set_viewport(Rect { x: 0, y: 0, w, h });
backend.render();
let step = Duration::from_secs_f64(1.0 / f64::from(fps));
let mut clock = Duration::ZERO;
sink(clock.as_secs_f64(), &backend.capture_frame())?;
for action in &actions {
if let super::Action::Wait(dt) = action {
hold(&mut backend, &mut sink, &mut clock, *dt, step)?;
continue;
}
super::execute(&mut backend, action)?;
clock += step;
sink(clock.as_secs_f64(), &backend.capture_frame())?;
hold(&mut backend, &mut sink, &mut clock, key_delay, step)?;
}
hold(&mut backend, &mut sink, &mut clock, settle, step)?;
Ok(())
}
pub fn record(self) -> Result<CastRecording> {
let (w, h) = self.viewport;
let title = self.title.clone();
let mut cast = CastRecording::new(w, h).title(title);
let mut last_time = 0.0;
self.play(|time, frame| {
last_time = time;
cast.push_frame(time, frame);
Ok(())
})?;
cast.mark_time(last_time);
Ok(cast)
}
#[cfg(feature = "ui-snapshot-png")]
pub fn write_frames(self, dir: impl AsRef<Path>) -> Result<Vec<PathBuf>> {
let dir = dir.as_ref().to_path_buf();
std::fs::create_dir_all(&dir)?;
let quiet = self.quiet;
let fps = self.fps;
let options = self.png_options.clone().unwrap_or_default();
let mut written: Vec<PathBuf> = Vec::new();
let mut previous: Option<(crate::capture::CapturedFrame, Vec<u8>)> = None;
self.play(|_, frame| {
let bytes = match previous.as_ref() {
Some((last, bytes)) if last == frame => bytes.clone(),
_ => frame.to_png(&options)?,
};
let path = dir.join(format!("frame_{:05}.png", written.len()));
std::fs::write(&path, &bytes)?;
previous = Some((frame.clone(), bytes));
written.push(path);
Ok(())
})?;
if !quiet {
println!("wrote {} frames to {}", written.len(), dir.display());
println!(
" ffmpeg -framerate {fps} -i {}/frame_%05d.png -pix_fmt yuv420p -movflags +faststart out.mp4",
dir.display()
);
}
Ok(written)
}
pub fn write(self, path: impl AsRef<Path>) -> Result<PathBuf> {
let path = path.as_ref().to_path_buf();
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
std::fs::create_dir_all(parent)?;
}
let quiet = self.quiet;
let cast = self.record()?;
cast.write(&path)?;
if !quiet {
println!(
"wrote {} ({} frames, {:.1}s)",
path.display(),
cast.len(),
cast.duration_secs()
);
}
Ok(path)
}
}
pub(crate) fn resolve_actions(
action_script: Option<&str>,
key_script: Option<&str>,
) -> Result<Vec<super::Action>> {
if let Some(script) = action_script {
return super::parse_script(script);
}
match key_script {
Some(script) => Ok(super::keys::parse_key_script(script)?
.into_iter()
.map(super::Action::Key)
.collect()),
None => Ok(Vec::new()),
}
}
fn hold<C: Component>(
backend: &mut TestBackend<C>,
sink: &mut impl FnMut(f64, &crate::capture::CapturedFrame) -> Result<()>,
clock: &mut Duration,
total: Duration,
step: Duration,
) -> Result<()> {
let mut remaining = total;
while !remaining.is_zero() {
let frame_span = step.min(remaining);
let mut advanced = Duration::ZERO;
while advanced < frame_span {
let tick = (frame_span - advanced).min(MAX_TICK);
backend.advance_frame(tick);
advanced += tick;
}
*clock += frame_span;
sink(clock.as_secs_f64(), &backend.capture_frame())?;
remaining = remaining.saturating_sub(frame_span);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::component::{Context, KeyUpdate, Update};
use crate::core::element::{IntoElement, Key};
use crate::core::event::{KeyCode, KeyEvent};
use crate::widgets::Text;
struct Echo;
impl Component for Echo {
type Message = ();
type Properties = ();
type State = String;
fn create_state(&self, _props: &Self::Properties) -> Self::State {
String::new()
}
fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
Update::none()
}
fn on_key(&mut self, key: KeyEvent, ctx: &mut Context<Self>) -> KeyUpdate {
if let KeyCode::Char(ch) = key.code {
ctx.state.push(ch);
return KeyUpdate::handled(Update::full());
}
KeyUpdate::unhandled(Update::none())
}
fn view(&self, ctx: &Context<Self>) -> Element {
Text::new(format!("typed:{}", ctx.state)).into()
}
}
fn static_view() -> Element {
Text::new("static").into()
}
#[test]
fn a_static_view_records_one_frame_plus_a_hold() {
let cast = Recording::view("static", static_view)
.viewport(20, 3)
.fps(30)
.settle(Duration::from_secs(2))
.quiet(true)
.record()
.expect("records");
assert_eq!(
cast.len(),
2,
"expected one frame and one hold marker, not 60 repeat frames"
);
assert!(
cast.duration_secs() >= 2.0,
"the hold should run the full settle: {}",
cast.duration_secs()
);
}
#[test]
fn each_key_produces_a_distinct_frame() {
let cast = Recording::view("keys", static_view)
.viewport(20, 3)
.settle(Duration::ZERO)
.quiet(true)
.record()
.expect("records");
let baseline = cast.len();
let typed = Recording::component("keys", Echo)
.viewport(20, 3)
.keys("a,b,c")
.key_delay(Duration::ZERO)
.settle(Duration::ZERO)
.quiet(true)
.record()
.expect("records");
assert_eq!(baseline, 1);
assert_eq!(
typed.len(),
4,
"initial frame plus one per keystroke that changed the view"
);
}
#[test]
fn the_clock_advances_with_key_delay_and_settle() {
let cast = Recording::component("timed", Echo)
.viewport(20, 3)
.fps(10)
.keys("a")
.key_delay(Duration::from_millis(500))
.settle(Duration::from_millis(500))
.quiet(true)
.record()
.expect("records");
assert!(
cast.duration_secs() >= 1.0,
"expected at least 1s of timeline, got {}",
cast.duration_secs()
);
}
#[test]
fn recording_is_deterministic_across_runs() {
let run = || {
Recording::component("determinism", Echo)
.viewport(24, 4)
.keys("h,i")
.key_delay(Duration::from_millis(100))
.settle(Duration::from_millis(100))
.quiet(true)
.record()
.expect("records")
.to_cast()
};
assert_eq!(run(), run(), "same script must produce identical bytes");
}
#[cfg(feature = "ui-snapshot-png")]
#[test]
fn frames_are_written_at_a_constant_rate() {
let dir = std::env::temp_dir().join(format!(
"tui-lipan-frames-rate-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let frames = Recording::view("rate", static_view)
.viewport(16, 2)
.fps(10)
.settle(Duration::from_millis(1000))
.quiet(true)
.write_frames(&dir)
.expect("writes frames");
assert_eq!(
frames.len(),
11,
"expected 1 opening frame + 10 ticks, got {}",
frames.len()
);
assert!(frames.iter().all(|path| path.exists()));
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "ui-snapshot-png")]
#[test]
fn frames_are_zero_padded_and_ordered_for_ffmpeg() {
let dir = std::env::temp_dir().join(format!(
"tui-lipan-frames-order-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let frames = Recording::view("order", static_view)
.viewport(16, 2)
.fps(4)
.settle(Duration::from_millis(500))
.quiet(true)
.write_frames(&dir)
.expect("writes frames");
let names: Vec<String> = frames
.iter()
.map(|path| path.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(names[0], "frame_00000.png");
assert_eq!(names[1], "frame_00001.png");
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "ui-snapshot-png")]
#[test]
fn frames_are_real_pngs_and_changed_frames_differ() {
let dir = std::env::temp_dir().join(format!(
"tui-lipan-frames-content-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let frames = Recording::component("content", Echo)
.viewport(20, 2)
.fps(4)
.keys("a,b")
.key_delay(Duration::ZERO)
.settle(Duration::ZERO)
.quiet(true)
.write_frames(&dir)
.expect("writes frames");
assert_eq!(frames.len(), 3, "opening frame plus one per key");
let bytes: Vec<Vec<u8>> = frames.iter().map(|p| std::fs::read(p).unwrap()).collect();
for (i, b) in bytes.iter().enumerate() {
assert!(
b.starts_with(&[0x89, b'P', b'N', b'G']),
"frame {i} is not a PNG"
);
}
assert_ne!(bytes[0], bytes[1], "typing should change the frame");
assert_ne!(bytes[1], bytes[2]);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "ui-snapshot-png")]
#[test]
fn unchanged_frames_reuse_identical_bytes() {
let dir = std::env::temp_dir().join(format!(
"tui-lipan-frames-reuse-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let frames = Recording::view("reuse", static_view)
.viewport(16, 2)
.fps(4)
.settle(Duration::from_millis(750))
.quiet(true)
.write_frames(&dir)
.expect("writes frames");
let first = std::fs::read(&frames[0]).unwrap();
for path in &frames[1..] {
assert_eq!(
std::fs::read(path).unwrap(),
first,
"a still view must produce byte-identical frames"
);
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_click_action_reaches_the_widget_it_targets() {
let cast = Recording::component("click", Clicker)
.viewport(30, 5)
.script("click:#go")
.key_delay(Duration::ZERO)
.settle(Duration::ZERO)
.quiet(true)
.record()
.expect("records");
let text = cast.to_cast();
assert!(
text.contains("clicked"),
"the click should reach the button"
);
}
#[test]
fn hover_marks_the_widget_under_the_pointer() {
let mut backend = TestBackend::new(Clicker);
backend.set_viewport(Rect {
x: 0,
y: 0,
w: 30,
h: 5,
});
backend.render();
assert!(backend.hovered().is_none(), "nothing hovered initially");
crate::ui_snapshot::execute(
&mut backend,
&crate::ui_snapshot::Action::Hover(crate::ui_snapshot::Target::Key(Key::from(
"go".to_owned(),
))),
)
.expect("hover succeeds");
let snapshot = backend.capture_ui_snapshot();
assert_eq!(
snapshot.hover_key.as_ref().map(|key| key.as_ref()),
Some("go"),
"the hovered widget should be reported in the snapshot"
);
}
#[test]
fn a_click_on_a_missing_key_fails_loudly() {
let err = Recording::component("missing", Clicker)
.viewport(30, 5)
.script("click:#nope")
.quiet(true)
.record()
.expect_err("a missing key must not silently click nothing");
assert!(err.to_string().contains("nope"), "{err}");
}
#[test]
fn wait_actions_spend_timeline_rather_than_input() {
let cast = Recording::view("waiting", static_view)
.viewport(20, 3)
.fps(10)
.script("wait:500")
.settle(Duration::ZERO)
.quiet(true)
.record()
.expect("records");
assert!(
cast.duration_secs() >= 0.5,
"a wait should advance the clock: {}",
cast.duration_secs()
);
}
struct Clicker;
impl Component for Clicker {
type Message = ();
type Properties = ();
type State = bool;
fn create_state(&self, _props: &Self::Properties) -> Self::State {
false
}
fn update(&mut self, _msg: Self::Message, ctx: &mut Context<Self>) -> Update {
ctx.state = true;
Update::full()
}
fn view(&self, ctx: &Context<Self>) -> Element {
let label = if ctx.state { "clicked" } else { "idle" };
crate::widgets::VStack::new()
.child(Text::new(label))
.child(
crate::widgets::Button::new("Go")
.on_click(ctx.link().callback(|_| ()))
.key("go"),
)
.into()
}
}
#[test]
fn an_invalid_key_script_is_reported() {
let err = Recording::view("bad", static_view)
.keys("tab,not-a-real-key")
.quiet(true)
.record()
.expect_err("unparseable script must fail");
assert!(err.to_string().contains("not-a-real-key"), "{err}");
}
#[test]
fn written_cast_starts_with_a_v2_header() {
let dir = std::env::temp_dir().join(format!("tui-lipan-cast-{}", std::process::id()));
let path = dir.join("demo.cast");
Recording::view("demo", static_view)
.viewport(20, 3)
.settle(Duration::ZERO)
.quiet(true)
.write(&path)
.expect("writes");
let text = std::fs::read_to_string(&path).expect("cast file");
assert!(text.starts_with("{\"version\":2"), "{text}");
std::fs::remove_dir_all(&dir).ok();
}
}