use std::io::{self, Write};
use ratatui_core::style::{Color, Style};
use ratatui_core::text::{Line, Span};
use crate::testing;
use crate::view::{AvailableSpace, MeasureRequest, RenderCtx, View};
use crate::{Size, Theme};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OneShotOptions {
pub width: u16,
pub max_height: u16,
pub trim_end: bool,
pub trailing_newline: bool,
}
impl Default for OneShotOptions {
fn default() -> Self {
Self {
width: 80,
max_height: 4096,
trim_end: true,
trailing_newline: true,
}
}
}
pub fn render_once(view: &dyn View, theme: &Theme, options: OneShotOptions) -> io::Result<String> {
let mut bytes = Vec::new();
write_once(&mut bytes, view, theme, options)?;
Ok(String::from_utf8(bytes).expect("terminal output is UTF-8"))
}
pub fn write_once(
out: &mut impl Write,
view: &dyn View,
theme: &Theme,
options: OneShotOptions,
) -> io::Result<()> {
if options.width == 0 || options.max_height == 0 {
return Ok(());
}
let request = MeasureRequest::new(Size::new(options.width, options.max_height))
.with_available_width(AvailableSpace::Definite(options.width))
.with_available_height(AvailableSpace::Definite(options.max_height));
let measured = view.measure_request(request, &RenderCtx::new(theme));
let size = Size::new(
measured.width.min(options.width),
measured.height.min(options.max_height),
);
if size.width == 0 || size.height == 0 {
return Ok(());
}
let buffer = testing::render(view, size.width, size.height, theme);
for row in 0..size.height {
let mut end = size.width;
if options.trim_end {
while end > 0 && buffer[(end - 1, row)].symbol() == " " {
end -= 1;
}
}
let mut spans = Vec::new();
let mut text = String::new();
let mut current = None;
for column in 0..end {
let cell = &buffer[(column, row)];
let style = cell_style(cell.fg, cell.bg, cell.modifier);
if current.is_some_and(|active| active != style) {
spans.push(Span::styled(std::mem::take(&mut text), current.unwrap()));
}
current = Some(style);
text.extend(
cell.symbol()
.chars()
.filter(|character| !character.is_control()),
);
}
if let Some(style) = current {
spans.push(Span::styled(text, style));
}
crate::term::hyperlink::write_line(out, &Line::from(spans))?;
if row + 1 < size.height || options.trailing_newline {
out.write_all(b"\n")?;
}
}
out.flush()
}
fn cell_style(
foreground: Color,
background: Color,
modifier: ratatui_core::style::Modifier,
) -> Style {
Style::default()
.fg(foreground)
.bg(background)
.add_modifier(modifier)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::components::Text;
use ratatui_core::style::{Color, Modifier};
struct FailingWriter;
impl Write for FailingWriter {
fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
Err(io::Error::other("sink closed"))
}
fn flush(&mut self) -> io::Result<()> {
Err(io::Error::other("sink closed"))
}
}
#[test]
fn one_shot_output_is_sized_to_content_and_keeps_newline() {
let output = render_once(
&Text::raw("hello"),
&Theme::default(),
OneShotOptions::default(),
)
.unwrap();
assert!(output.contains("hello"));
assert!(output.ends_with('\n'));
assert!(!output.contains(
"hello "
));
}
#[test]
fn one_shot_output_drops_embedded_control_characters() {
let output = render_once(
&Text::raw("safe\u{1b}[31munsafe"),
&Theme::default(),
OneShotOptions::default(),
)
.unwrap();
assert!(!output.contains("\u{1b}[31munsafe"));
}
#[test]
fn one_shot_output_preserves_cell_sgr_without_owning_terminal_state() {
let style = Style::default()
.fg(Color::Indexed(201))
.add_modifier(Modifier::BOLD);
let view = crate::view::DrawView::new(
move |area: ratatui_core::layout::Rect, surface: &mut crate::Surface, _: &RenderCtx| {
surface.set_string(area.x, area.y, "styled", style);
},
)
.intrinsic_size(Size::new(6, 1));
let output = render_once(&view, &Theme::default(), OneShotOptions::default()).unwrap();
assert!(output.contains("\x1b[1m"), "{output:?}");
assert!(
output.contains("\x1b[0m"),
"style must be reset: {output:?}"
);
assert!(!output.contains("\x1b[?1049h"));
assert!(!output.contains("\x1b[?25l"));
}
#[test]
fn one_shot_output_propagates_writer_errors() {
let error = write_once(
&mut FailingWriter,
&Text::raw("hello"),
&Theme::default(),
OneShotOptions::default(),
)
.expect_err("writer failure must reach the caller");
assert_eq!(error.kind(), io::ErrorKind::Other);
assert_eq!(error.to_string(), "sink closed");
}
}