#![forbid(unsafe_code)]
use ftui_core::event::{Event, KeyCode, KeyEvent};
use ftui_core::terminal_capabilities::TerminalCapabilities;
use ftui_render::cell::Cell;
use ftui_render::frame::Frame;
use ftui_render::grapheme_pool::GraphemePool;
use ftui_runtime::program::{Cmd, Model, Program, ProgramConfig};
use ftui_runtime::subscription::{Every, Subscription};
use ftui_runtime::terminal_writer::TerminalWriter;
use ftui_runtime::{
BackendEventSource, BackendFeatures, ProcessEvent, ProcessSubscription, ScreenMode,
};
use std::io::{self, Write};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
const SYNC_BEGIN: &[u8] = b"\x1b[?2026h";
const SYNC_END: &[u8] = b"\x1b[?2026l";
const CURSOR_SAVE: &[u8] = b"\x1b7";
#[derive(Clone, Default)]
struct CaptureSink {
bytes: Arc<Mutex<Vec<u8>>>,
}
impl CaptureSink {
fn snapshot(&self) -> Vec<u8> {
self.bytes
.lock()
.map(|bytes| bytes.clone())
.unwrap_or_default()
}
}
impl Write for CaptureSink {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let mut bytes = self
.bytes
.lock()
.map_err(|_| io::Error::other("capture sink poisoned"))?;
bytes.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
struct ScriptedSource {
width: u16,
height: u16,
features: BackendFeatures,
steps_remaining: usize,
quit_sent: bool,
}
impl ScriptedSource {
fn new(width: u16, height: u16, features: BackendFeatures, steps: usize) -> Self {
Self {
width,
height,
features,
steps_remaining: steps,
quit_sent: false,
}
}
}
impl BackendEventSource for ScriptedSource {
type Error = io::Error;
fn size(&self) -> Result<(u16, u16), io::Error> {
Ok((self.width, self.height))
}
fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
self.features = features;
Ok(())
}
fn poll_event(&mut self, _timeout: Duration) -> Result<bool, io::Error> {
Ok(self.steps_remaining > 0 || !self.quit_sent)
}
fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
if self.steps_remaining > 0 {
self.steps_remaining -= 1;
return Ok(Some(Event::Key(KeyEvent::new(KeyCode::Char('n')))));
}
if !self.quit_sent {
self.quit_sent = true;
return Ok(Some(Event::Key(KeyEvent::new(KeyCode::Char('q')))));
}
Ok(None)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SubSpec {
None,
Ticks,
TicksAndProcess,
Churn,
}
#[derive(Debug, Clone)]
enum Msg {
Step,
Tick,
Proc(ProcessEvent),
Quit,
}
impl From<Event> for Msg {
fn from(event: Event) -> Self {
match event {
Event::Key(key) if key.code == KeyCode::Char('q') => Msg::Quit,
_ => Msg::Step,
}
}
}
struct NiModel {
spec: SubSpec,
steps: u32,
tick_hits: usize,
process_hits: usize,
}
impl NiModel {
fn new(spec: SubSpec) -> Self {
Self {
spec,
steps: 0,
tick_hits: 0,
process_hits: 0,
}
}
}
fn tick_subs() -> Vec<Box<dyn Subscription<Msg>>> {
vec![
Box::new(Every::with_id(0xA1, Duration::from_millis(5), || Msg::Tick)),
Box::new(Every::with_id(0xB2, Duration::from_millis(7), || Msg::Tick)),
]
}
impl Model for NiModel {
type Message = Msg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
Msg::Step => {
self.steps += 1;
Cmd::none()
}
Msg::Tick => {
self.tick_hits += 1;
Cmd::none()
}
Msg::Proc(event) => {
if matches!(
event,
ProcessEvent::Exited(_)
| ProcessEvent::Signaled(_)
| ProcessEvent::Killed
| ProcessEvent::Error(_)
) {
self.process_hits += 1;
}
Cmd::none()
}
Msg::Quit => Cmd::quit(),
}
}
fn view(&self, frame: &mut Frame) {
let text = format!("steps={}", self.steps);
for (idx, ch) in text.chars().enumerate() {
if (idx as u16) < frame.width() {
frame.buffer.set_raw(idx as u16, 0, Cell::from_char(ch));
}
}
}
fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
match self.spec {
SubSpec::None => Vec::new(),
SubSpec::Ticks => tick_subs(),
SubSpec::TicksAndProcess => {
let mut subs = tick_subs();
subs.push(Box::new(
ProcessSubscription::new("sleep", Msg::Proc).arg("60"),
));
subs
}
SubSpec::Churn => match self.steps % 3 {
0 => tick_subs(),
1 => vec![Box::new(Every::with_id(
0xA1,
Duration::from_millis(5),
|| Msg::Tick,
))],
_ => vec![
Box::new(Every::with_id(0xA1, Duration::from_millis(5), || Msg::Tick)),
Box::new(Every::with_id(0xC3, Duration::from_millis(9), || Msg::Tick)),
],
},
}
}
}
struct Outcome {
terminal_output: Vec<u8>,
frame_hash: u64,
running: bool,
elapsed: Duration,
}
const STEPS: usize = 4;
const WIDTH: u16 = 40;
const HEIGHT: u16 = 12;
fn run_scenario(screen_mode: ScreenMode, spec: SubSpec) -> Outcome {
let mut config = ProgramConfig::default().with_forced_size(WIDTH, HEIGHT);
config.screen_mode = screen_mode;
config.poll_timeout = Duration::ZERO;
config.intercept_signals = false;
let capabilities = TerminalCapabilities::basic();
let initial_features = BackendFeatures {
mouse_capture: config.resolved_mouse_capture(),
bracketed_paste: config.bracketed_paste,
focus_events: config.focus_reporting,
kitty_keyboard: config.kitty_keyboard,
};
let sink = CaptureSink::default();
let writer = TerminalWriter::with_diff_config(
sink.clone(),
config.screen_mode,
config.ui_anchor,
capabilities,
config.diff_config.clone(),
);
let model = NiModel::new(spec);
let events = ScriptedSource::new(WIDTH, HEIGHT, initial_features, STEPS);
let start = Instant::now();
let mut program = Program::with_event_source(model, events, initial_features, writer, config)
.expect("headless program for non-interference harness");
program.run().expect("run non-interference scenario");
let elapsed = start.elapsed();
let frame_hash = render_model_frame_hash(program.model(), WIDTH, HEIGHT);
Outcome {
terminal_output: sink.snapshot(),
frame_hash,
running: program.is_running(),
elapsed,
}
}
fn render_model_frame_hash(model: &NiModel, width: u16, height: u16) -> u64 {
use std::hash::{Hash, Hasher};
let mut pool = GraphemePool::new();
let mut frame = Frame::new(width, height, &mut pool);
model.view(&mut frame);
let buf = &frame.buffer;
let mut hasher = std::collections::hash_map::DefaultHasher::new();
buf.width().hash(&mut hasher);
buf.height().hash(&mut hasher);
for y in 0..buf.height() {
for x in 0..buf.width() {
if let Some(cell) = buf.get(x, y) {
cell.content.as_char().hash(&mut hasher);
}
}
}
hasher.finish()
}
fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
!needle.is_empty()
&& haystack.len() >= needle.len()
&& haystack.windows(needle.len()).any(|w| w == needle)
}
fn count_subslices(haystack: &[u8], needle: &[u8]) -> usize {
if needle.is_empty() || haystack.len() < needle.len() {
return 0;
}
let mut count = 0;
let mut i = 0;
while i + needle.len() <= haystack.len() {
if &haystack[i..i + needle.len()] == needle {
count += 1;
i += needle.len();
} else {
i += 1;
}
}
count
}
const INLINE: ScreenMode = ScreenMode::Inline { ui_height: 6 };
const ALT: ScreenMode = ScreenMode::AltScreen;
#[test]
fn render_unaffected_by_subscriptions_inline() {
let baseline = run_scenario(INLINE, SubSpec::None);
assert!(!baseline.running, "baseline program must stop cleanly");
for spec in [SubSpec::Ticks, SubSpec::TicksAndProcess, SubSpec::Churn] {
let outcome = run_scenario(INLINE, spec);
assert_eq!(
outcome.frame_hash, baseline.frame_hash,
"subscriptions must not perturb the inline-rendered frame (spec {spec:?})"
);
assert!(
!outcome.running,
"program must stop cleanly (spec {spec:?})"
);
}
}
#[test]
fn render_unaffected_by_subscriptions_altscreen() {
let baseline = run_scenario(ALT, SubSpec::None);
assert!(!baseline.running, "baseline program must stop cleanly");
for spec in [SubSpec::Ticks, SubSpec::TicksAndProcess, SubSpec::Churn] {
let outcome = run_scenario(ALT, spec);
assert_eq!(
outcome.frame_hash, baseline.frame_hash,
"subscriptions must not perturb the alt-screen-rendered frame (spec {spec:?})"
);
assert!(
!outcome.running,
"program must stop cleanly (spec {spec:?})"
);
}
}
#[test]
fn frame_hash_deterministic_across_repeated_runs() {
for mode in [INLINE, ALT] {
let hashes: Vec<u64> = (0..4)
.map(|_| run_scenario(mode, SubSpec::TicksAndProcess).frame_hash)
.collect();
assert!(
hashes.windows(2).all(|w| w[0] == w[1]),
"render must be deterministic under concurrent subscription churn (mode {mode:?}): {hashes:?}"
);
}
}
#[test]
fn terminal_output_well_formed_under_churn() {
for mode in [INLINE, ALT] {
let outcome = run_scenario(mode, SubSpec::Churn);
assert!(
!outcome.terminal_output.is_empty(),
"at least one frame must be presented (mode {mode:?})"
);
let begins = count_subslices(&outcome.terminal_output, SYNC_BEGIN);
let ends = count_subslices(&outcome.terminal_output, SYNC_END);
assert_eq!(
begins, ends,
"every synchronized-output begin must be matched by an end (mode {mode:?}): {begins} begins vs {ends} ends"
);
}
}
#[test]
fn inline_and_altscreen_modes_stay_distinct_with_subscriptions() {
let inline = run_scenario(INLINE, SubSpec::Ticks);
let alt = run_scenario(ALT, SubSpec::Ticks);
assert!(
contains_subslice(&inline.terminal_output, CURSOR_SAVE),
"inline mode must emit DEC cursor-save (ESC 7) with subscriptions active"
);
assert!(
!contains_subslice(&alt.terminal_output, CURSOR_SAVE),
"alt-screen mode must not emit inline cursor-save gymnastics"
);
}
#[test]
fn shutdown_bounded_with_live_process_and_ticks() {
for mode in [INLINE, ALT] {
let outcome = run_scenario(mode, SubSpec::TicksAndProcess);
assert!(!outcome.running, "program must stop (mode {mode:?})");
assert!(
outcome.elapsed < Duration::from_secs(5),
"shutdown with a live `sleep 60` child + ticks must be bounded, took {:?} (mode {mode:?})",
outcome.elapsed
);
}
}
#[test]
fn restart_churn_stops_cleanly() {
for mode in [INLINE, ALT] {
let outcome = run_scenario(mode, SubSpec::Churn);
assert!(
!outcome.running,
"program must stop after churn (mode {mode:?})"
);
assert!(
outcome.elapsed < Duration::from_secs(5),
"start/stop/restart churn must not stall shutdown, took {:?} (mode {mode:?})",
outcome.elapsed
);
}
}