#![forbid(unsafe_code)]
use ftui_core::terminal_capabilities::TerminalCapabilities;
use ftui_render::buffer::Buffer;
use ftui_runtime::{ScreenMode, TerminalWriter, UiAnchor, inline_active_widgets};
fn basic_caps() -> TerminalCapabilities {
TerminalCapabilities::basic()
}
fn full_caps() -> TerminalCapabilities {
let mut caps = TerminalCapabilities::basic();
caps.true_color = true;
caps.sync_output = true;
caps
}
fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
haystack
.windows(needle.len())
.any(|window| window == needle)
}
const ALTSCREEN_ENTER: &[u8] = b"\x1b[?1049h";
const ALTSCREEN_EXIT: &[u8] = b"\x1b[?1049l";
const CURSOR_SAVE: &[u8] = b"\x1b7";
const CURSOR_RESTORE: &[u8] = b"\x1b8";
#[test]
fn write_100_lines_then_render_inline_widget() {
let mut output = Vec::new();
{
let mut writer = TerminalWriter::new(
&mut output,
ScreenMode::Inline { ui_height: 5 },
UiAnchor::Bottom,
basic_caps(),
);
writer.set_size(80, 24);
for i in 1..=100 {
writer
.write_log(&format!("scrollback line {i:03}\n"))
.unwrap();
}
let buffer = Buffer::new(80, 5);
writer.present_ui(&buffer, None, true).unwrap();
}
let text = String::from_utf8_lossy(&output);
for i in 1..=100 {
assert!(
text.contains(&format!("scrollback line {i:03}")),
"scrollback line {i:03} must be present in output"
);
}
assert!(
!contains_bytes(&output, ALTSCREEN_ENTER),
"inline mode must never emit CSI ?1049h"
);
assert!(
!contains_bytes(&output, ALTSCREEN_EXIT),
"inline mode must never emit CSI ?1049l"
);
}
#[test]
fn scrollback_accessible_above_widget() {
let mut output = Vec::new();
{
let mut writer = TerminalWriter::new(
&mut output,
ScreenMode::Inline { ui_height: 5 },
UiAnchor::Bottom,
basic_caps(),
);
writer.set_size(80, 24);
for i in 1..=100 {
writer.write_log(&format!("log line {i:03}\n")).unwrap();
}
let buffer = Buffer::new(80, 5);
writer.present_ui(&buffer, None, true).unwrap();
writer.write_log("post-render log\n").unwrap();
}
let text = String::from_utf8_lossy(&output);
assert!(text.contains("log line 001"), "first log must survive");
assert!(text.contains("log line 050"), "middle log must survive");
assert!(text.contains("log line 100"), "last log must survive");
assert!(
text.contains("post-render log"),
"post-render log must survive"
);
assert!(
contains_bytes(&output, CURSOR_SAVE),
"present_ui must save cursor to protect scrollback"
);
assert!(
contains_bytes(&output, CURSOR_RESTORE),
"present_ui must restore cursor to protect scrollback"
);
}
#[test]
fn scrollback_intact_after_widget_removal() {
let mut output = Vec::new();
{
let mut writer = TerminalWriter::new(
&mut output,
ScreenMode::Inline { ui_height: 5 },
UiAnchor::Bottom,
basic_caps(),
);
writer.set_size(80, 24);
for i in 1..=100 {
writer
.write_log(&format!("preserved line {i:03}\n"))
.unwrap();
}
let buffer = Buffer::new(80, 5);
writer.present_ui(&buffer, None, true).unwrap();
}
let text = String::from_utf8_lossy(&output);
for i in 1..=100 {
assert!(
text.contains(&format!("preserved line {i:03}")),
"preserved line {i:03} must survive widget removal"
);
}
assert!(
!contains_bytes(&output, ALTSCREEN_ENTER),
"inline mode must never emit alternate screen enter"
);
}
#[test]
fn multiple_render_passes_preserve_scrollback() {
let mut output = Vec::new();
{
let mut writer = TerminalWriter::new(
&mut output,
ScreenMode::Inline { ui_height: 5 },
UiAnchor::Bottom,
basic_caps(),
);
writer.set_size(80, 24);
for batch in 0..10 {
for i in 0..10 {
let line_num = batch * 10 + i + 1;
writer.write_log(&format!("line {line_num:03}\n")).unwrap();
}
let buffer = Buffer::new(80, 5);
writer.present_ui(&buffer, None, true).unwrap();
}
}
let text = String::from_utf8_lossy(&output);
for i in 1..=100 {
assert!(
text.contains(&format!("line {i:03}")),
"line {i:03} must survive interleaved render passes"
);
}
}
#[test]
fn full_e2e_lifecycle_scrollback_preserved() {
let mut output = Vec::new();
{
let mut writer = TerminalWriter::new(
&mut output,
ScreenMode::Inline { ui_height: 5 },
UiAnchor::Bottom,
basic_caps(),
);
writer.set_size(80, 24);
for i in 1..=100 {
writer.write_log(&format!("scrollback {i:03}\n")).unwrap();
}
let buffer = Buffer::new(80, 5);
writer.present_ui(&buffer, None, true).unwrap();
writer.write_log("mid-session log A\n").unwrap();
writer.write_log("mid-session log B\n").unwrap();
let buffer2 = Buffer::new(80, 5);
writer.present_ui(&buffer2, None, true).unwrap();
}
let text = String::from_utf8_lossy(&output);
assert!(text.contains("scrollback 001"), "first line");
assert!(text.contains("scrollback 050"), "middle line");
assert!(text.contains("scrollback 100"), "last line");
assert!(text.contains("mid-session log A"), "mid-session A");
assert!(text.contains("mid-session log B"), "mid-session B");
assert!(
!contains_bytes(&output, ALTSCREEN_ENTER),
"must not enter alt screen"
);
assert!(
contains_bytes(&output, CURSOR_SAVE),
"must save cursor during inline render"
);
assert!(
contains_bytes(&output, CURSOR_RESTORE),
"must restore cursor during inline render"
);
}
#[test]
fn inline_auto_mode_preserves_scrollback() {
let mut output = Vec::new();
{
let mut writer = TerminalWriter::new(
&mut output,
ScreenMode::InlineAuto {
min_height: 3,
max_height: 10,
},
UiAnchor::Bottom,
basic_caps(),
);
writer.set_size(80, 24);
for i in 1..=100 {
writer.write_log(&format!("auto line {i:03}\n")).unwrap();
}
let buffer = Buffer::new(80, 5);
writer.present_ui(&buffer, None, true).unwrap();
}
let text = String::from_utf8_lossy(&output);
assert!(text.contains("auto line 001"), "first line in InlineAuto");
assert!(text.contains("auto line 100"), "last line in InlineAuto");
assert!(
!contains_bytes(&output, ALTSCREEN_ENTER),
"InlineAuto must never emit alternate screen enter"
);
}
#[test]
fn inline_widget_gauge_tracks_lifecycle() {
for _ in 0..64 {
let before = inline_active_widgets();
let mut writer = TerminalWriter::new(
Vec::new(),
ScreenMode::Inline { ui_height: 5 },
UiAnchor::Bottom,
basic_caps(),
);
writer.set_size(80, 24);
let during = inline_active_widgets();
writer.write_log("gauge test\n").unwrap();
let buffer = Buffer::new(80, 5);
writer.present_ui(&buffer, None, true).unwrap();
drop(writer);
let after = inline_active_widgets();
if during == before.saturating_add(1) && after == before {
return;
}
std::thread::yield_now();
}
panic!("failed to observe uncontended gauge lifecycle transitions after 64 retries");
}
#[test]
fn full_caps_inline_mode_preserves_scrollback() {
let mut output = Vec::new();
{
let mut writer = TerminalWriter::new(
&mut output,
ScreenMode::Inline { ui_height: 5 },
UiAnchor::Bottom,
full_caps(),
);
writer.set_size(80, 24);
for i in 1..=100 {
writer.write_log(&format!("sync line {i:03}\n")).unwrap();
}
let buffer = Buffer::new(80, 5);
writer.present_ui(&buffer, None, true).unwrap();
}
let text = String::from_utf8_lossy(&output);
assert!(text.contains("sync line 001"), "first line with sync caps");
assert!(text.contains("sync line 100"), "last line with sync caps");
assert!(
!contains_bytes(&output, ALTSCREEN_ENTER),
"inline mode must never emit alternate screen even with full caps"
);
assert!(
contains_bytes(&output, CURSOR_SAVE),
"must save cursor with full caps"
);
assert!(
contains_bytes(&output, CURSOR_RESTORE),
"must restore cursor with full caps"
);
}
#[test]
fn resize_between_renders_preserves_scrollback() {
let mut output = Vec::new();
{
let mut writer = TerminalWriter::new(
&mut output,
ScreenMode::Inline { ui_height: 5 },
UiAnchor::Bottom,
basic_caps(),
);
writer.set_size(80, 24);
for i in 1..=50 {
writer
.write_log(&format!("before resize {i:03}\n"))
.unwrap();
}
let buffer = Buffer::new(80, 5);
writer.present_ui(&buffer, None, true).unwrap();
writer.set_size(120, 40);
for i in 51..=100 {
writer.write_log(&format!("after resize {i:03}\n")).unwrap();
}
let buffer2 = Buffer::new(120, 5);
writer.present_ui(&buffer2, None, true).unwrap();
}
let text = String::from_utf8_lossy(&output);
assert!(
text.contains("before resize 001"),
"pre-resize scrollback must survive"
);
assert!(
text.contains("before resize 050"),
"last pre-resize line must survive"
);
assert!(
text.contains("after resize 051"),
"first post-resize line must survive"
);
assert!(
text.contains("after resize 100"),
"last post-resize line must survive"
);
assert!(
!contains_bytes(&output, ALTSCREEN_ENTER),
"resize must not trigger alternate screen"
);
}
#[test]
fn into_inner_preserves_scrollback() {
let mut writer = TerminalWriter::new(
Vec::new(),
ScreenMode::Inline { ui_height: 5 },
UiAnchor::Bottom,
basic_caps(),
);
writer.set_size(80, 24);
for i in 1..=100 {
writer.write_log(&format!("inner line {i:03}\n")).unwrap();
}
let buffer = Buffer::new(80, 5);
writer.present_ui(&buffer, None, true).unwrap();
let output = writer.into_inner().expect("into_inner should succeed");
let text = String::from_utf8_lossy(&output);
assert!(text.contains("inner line 001"), "first line via into_inner");
assert!(text.contains("inner line 100"), "last line via into_inner");
assert!(
!contains_bytes(&output, ALTSCREEN_ENTER),
"into_inner must not emit alternate screen"
);
}