#![forbid(unsafe_code)]
use std::borrow::Cow;
use std::io::{self, Write};
use ftui_render::sanitize::sanitize;
use ftui_style::Style;
use ftui_text::{Segment, display_width, grapheme_width};
use unicode_segmentation::UnicodeSegmentation;
#[cfg(test)]
use ftui_render::cell::PackedRgba;
#[cfg(test)]
use ftui_style::StyleFlags;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WrapMode {
None,
#[default]
Word,
Character,
}
pub enum ConsoleSink {
Capture(Vec<CapturedLine>),
Writer(Box<dyn Write + Send>),
}
impl ConsoleSink {
#[must_use]
pub fn capture() -> Self {
Self::Capture(Vec::new())
}
pub fn writer<W: Write + Send + 'static>(w: W) -> Self {
Self::Writer(Box::new(w))
}
fn write_line(&mut self, line: &ConsoleBuffer) -> io::Result<()> {
match self {
Self::Capture(lines) => {
lines.push(line.to_captured());
Ok(())
}
Self::Writer(w) => {
for seg in &line.segments {
let safe = sanitize(&seg.text);
w.write_all(safe.as_bytes())?;
}
w.write_all(b"\n")?;
Ok(())
}
}
}
fn flush(&mut self) -> io::Result<()> {
match self {
Self::Capture(_) => Ok(()),
Self::Writer(w) => w.flush(),
}
}
#[must_use]
pub fn captured(&self) -> Option<&[CapturedLine]> {
match self {
Self::Capture(lines) => Some(lines),
Self::Writer(_) => None,
}
}
}
impl std::fmt::Debug for ConsoleSink {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Capture(lines) => f.debug_tuple("Capture").field(&lines.len()).finish(),
Self::Writer(_) => f.debug_tuple("Writer").finish(),
}
}
}
#[derive(Debug, Clone)]
pub struct CapturedLine {
pub segments: Vec<CapturedSegment>,
}
impl CapturedLine {
#[must_use]
pub fn from_plain(text: &str) -> Self {
Self {
segments: vec![CapturedSegment {
text: text.to_string(),
style: Style::default(),
}],
}
}
#[must_use]
pub fn plain_text(&self) -> String {
self.segments.iter().map(|s| s.text.as_str()).collect()
}
#[must_use]
pub fn width(&self) -> usize {
self.segments.iter().map(|s| display_width(&s.text)).sum()
}
}
#[derive(Debug, Clone)]
pub struct CapturedSegment {
pub text: String,
pub style: Style,
}
#[derive(Debug, Clone, Default)]
struct ConsoleBuffer {
segments: Vec<BufferSegment>,
width: usize,
}
#[derive(Debug, Clone)]
struct BufferSegment {
text: String,
style: Style,
}
impl ConsoleBuffer {
fn new() -> Self {
Self::default()
}
fn push(&mut self, text: &str, style: Style) {
let width = display_width(text);
self.segments.push(BufferSegment {
text: text.to_string(),
style,
});
self.width += width;
}
fn clear(&mut self) {
self.segments.clear();
self.width = 0;
}
fn is_empty(&self) -> bool {
self.segments.is_empty() || self.segments.iter().all(|s| s.text.is_empty())
}
fn to_captured(&self) -> CapturedLine {
CapturedLine {
segments: self
.segments
.iter()
.map(|s| CapturedSegment {
text: s.text.clone(),
style: s.style,
})
.collect(),
}
}
}
pub struct Console {
width: usize,
sink: ConsoleSink,
wrap_mode: WrapMode,
style_stack: Vec<Style>,
current_line: ConsoleBuffer,
line_count: usize,
}
impl Console {
#[must_use]
pub fn new(width: usize, sink: ConsoleSink) -> Self {
Self::with_options(width, sink, WrapMode::Word)
}
#[must_use]
pub fn with_options(width: usize, sink: ConsoleSink, wrap_mode: WrapMode) -> Self {
Self {
width,
sink,
wrap_mode,
style_stack: Vec::new(),
current_line: ConsoleBuffer::new(),
line_count: 0,
}
}
#[must_use]
pub const fn width(&self) -> usize {
self.width
}
#[must_use]
pub const fn line_count(&self) -> usize {
self.line_count
}
#[must_use]
pub fn current_style(&self) -> Style {
let mut merged = Style::default();
for style in &self.style_stack {
merged = merged.merge(style);
}
merged
}
pub fn push_style(&mut self, style: Style) {
self.style_stack.push(style);
}
pub fn pop_style(&mut self) -> Option<Style> {
self.style_stack.pop()
}
pub fn clear_styles(&mut self) {
self.style_stack.clear();
}
pub fn print(&mut self, segment: Segment<'_>) {
let base_style = self.current_style();
let style = if let Some(seg_style) = segment.style {
base_style.merge(&seg_style)
} else {
base_style
};
let text = segment.text.as_ref();
if let Some(controls) = &segment.control {
for control in controls {
if control.is_newline() {
self.newline();
} else if control.is_cr() {
self.current_line.clear();
}
}
}
if text.is_empty() {
return;
}
match self.wrap_mode {
WrapMode::None => {
self.current_line.push(text, style);
}
WrapMode::Word => {
self.print_word_wrapped(text, style);
}
WrapMode::Character => {
self.print_char_wrapped(text, style);
}
}
}
fn print_word_wrapped(&mut self, text: &str, style: Style) {
let mut remaining = text;
while !remaining.is_empty() {
let remaining_width = self.width.saturating_sub(self.current_line.width);
if remaining_width == 0 {
self.flush_line();
continue;
}
let (word, rest) = split_next_word(remaining);
if word.is_empty() {
if !rest.is_empty() {
self.current_line.push(rest, style);
}
break;
}
let word_width = display_width(word);
if word_width <= remaining_width {
self.current_line.push(word, style);
remaining = rest;
} else if self.current_line.is_empty() {
let (fits, _overflow) = split_at_width(word, remaining_width);
if !fits.is_empty() {
self.current_line.push(fits, style);
self.flush_line();
remaining = &remaining[fits.len()..];
} else {
let first_grapheme_end = word
.grapheme_indices(true)
.nth(1)
.map(|(i, _)| i)
.unwrap_or(word.len());
self.current_line.push(&word[..first_grapheme_end], style);
self.flush_line();
remaining = &remaining[first_grapheme_end..];
}
} else {
self.flush_line();
}
}
}
fn print_char_wrapped(&mut self, text: &str, style: Style) {
let mut remaining = text;
while !remaining.is_empty() {
let remaining_width = self.width.saturating_sub(self.current_line.width);
if remaining_width == 0 {
self.flush_line();
continue;
}
let (fits, overflow) = split_at_width(remaining, remaining_width);
if !fits.is_empty() {
self.current_line.push(fits, style);
remaining = overflow;
} else {
if self.current_line.is_empty() {
let first_grapheme_end = remaining
.grapheme_indices(true)
.nth(1)
.map(|(i, _)| i)
.unwrap_or(remaining.len());
self.current_line
.push(&remaining[..first_grapheme_end], style);
remaining = &remaining[first_grapheme_end..];
}
self.flush_line();
}
}
}
pub fn print_styled(&mut self, text: impl Into<Cow<'static, str>>, style: Style) {
self.print(Segment::styled(text, style));
}
pub fn print_text(&mut self, text: impl Into<Cow<'static, str>>) {
self.print(Segment::text(text));
}
pub fn newline(&mut self) {
self.flush_line();
}
pub fn println(&mut self, segment: Segment<'_>) {
self.print(segment);
self.newline();
}
pub fn println_styled(&mut self, text: impl Into<Cow<'static, str>>, style: Style) {
self.print_styled(text, style);
self.newline();
}
pub fn println_text(&mut self, text: impl Into<Cow<'static, str>>) {
self.print_text(text);
self.newline();
}
pub fn blank_line(&mut self) {
self.flush_line();
let _ = self.sink.write_line(&ConsoleBuffer::new());
self.line_count += 1;
}
pub fn rule(&mut self, char: char) {
self.flush_line();
let rule_text: String = std::iter::repeat_n(char, self.width).collect();
self.current_line.push(&rule_text, self.current_style());
self.flush_line();
}
fn flush_line(&mut self) {
if !self.current_line.is_empty() {
let _ = self.sink.write_line(&self.current_line);
self.line_count += 1;
}
self.current_line.clear();
}
pub fn flush(&mut self) -> io::Result<()> {
self.flush_line();
self.sink.flush()
}
#[must_use]
pub fn into_captured(mut self) -> String {
self.flush_line();
match self.sink {
ConsoleSink::Capture(lines) => lines
.iter()
.map(CapturedLine::plain_text)
.collect::<Vec<_>>()
.join("\n"),
ConsoleSink::Writer(_) => String::new(),
}
}
#[must_use]
pub fn into_captured_lines(mut self) -> Vec<CapturedLine> {
self.flush_line();
match self.sink {
ConsoleSink::Capture(lines) => lines,
ConsoleSink::Writer(_) => Vec::new(),
}
}
#[must_use]
pub fn sink(&self) -> &ConsoleSink {
&self.sink
}
}
fn split_next_word(text: &str) -> (&str, &str) {
let start = text
.char_indices()
.find(|(_, c)| !c.is_whitespace())
.map(|(i, _)| i)
.unwrap_or(text.len());
if start == text.len() {
return (text, "");
}
let end = text[start..]
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(i, _)| start + i)
.unwrap_or(text.len());
let end_with_space = if end < text.len() && text[end..].starts_with(' ') {
end + 1
} else {
end
};
(&text[..end_with_space], &text[end_with_space..])
}
fn split_at_width(text: &str, max_width: usize) -> (&str, &str) {
if display_width(text) <= max_width {
return (text, "");
}
let mut width = 0;
let mut split_idx = 0;
for grapheme in text.graphemes(true) {
let g_width = grapheme_width(grapheme);
if width + g_width > max_width {
break;
}
width += g_width;
split_idx += grapheme.len();
}
(&text[..split_idx], &text[split_idx..])
}
#[cfg(test)]
mod tests {
use super::*;
const RED: PackedRgba = PackedRgba::rgb(255, 0, 0);
const BLUE: PackedRgba = PackedRgba::rgb(0, 0, 255);
#[test]
fn console_basic_output() {
let sink = ConsoleSink::capture();
let mut console = Console::new(80, sink);
console.print_text("Hello, world!");
console.newline();
let output = console.into_captured();
assert_eq!(output, "Hello, world!");
}
#[test]
fn console_styled_output() {
let sink = ConsoleSink::capture();
let mut console = Console::new(80, sink);
console.print(Segment::styled("Bold", Style::new().bold()));
console.newline();
let lines = console.into_captured_lines();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].segments[0].text, "Bold");
assert!(lines[0].segments[0].style.has_attr(StyleFlags::BOLD));
}
#[test]
fn console_style_stack() {
let sink = ConsoleSink::capture();
let mut console = Console::with_options(80, sink, WrapMode::None);
console.push_style(Style::new().fg(RED));
console.print_text("Red");
console.push_style(Style::new().bold());
console.print_text("Bold");
console.pop_style();
console.print_text("Red");
console.newline();
let lines = console.into_captured_lines();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].segments.len(), 3);
assert_eq!(lines[0].segments[0].style.fg, Some(RED));
assert!(!lines[0].segments[0].style.has_attr(StyleFlags::BOLD));
assert_eq!(lines[0].segments[1].style.fg, Some(RED));
assert!(lines[0].segments[1].style.has_attr(StyleFlags::BOLD));
assert_eq!(lines[0].segments[2].style.fg, Some(RED));
assert!(!lines[0].segments[2].style.has_attr(StyleFlags::BOLD));
}
#[test]
fn console_word_wrap() {
let sink = ConsoleSink::capture();
let mut console = Console::with_options(20, sink, WrapMode::Word);
console.print_text("This is a test of word wrapping in the console.");
console.flush().unwrap();
let lines = console.into_captured_lines();
assert!(lines.len() > 1);
for line in &lines {
assert!(line.width() <= 20, "Line too wide: {:?}", line.plain_text());
}
}
#[test]
fn console_word_wrap_long_word_with_rest() {
let sink = ConsoleSink::capture();
let mut console = Console::with_options(10, sink, WrapMode::Word);
console.print_text("superlongword more text");
console.flush().unwrap();
let lines = console.into_captured_lines();
let all_text: String = lines
.iter()
.map(|l| l.plain_text())
.collect::<Vec<_>>()
.join("");
assert_eq!(all_text.replace(" ", ""), "superlongwordmoretext");
for line in &lines {
assert!(line.width() <= 10, "Line too wide: {:?}", line.plain_text());
}
}
#[test]
fn console_word_wrap_wide_char_boundary() {
let sink = ConsoleSink::capture();
let mut console = Console::with_options(3, sink, WrapMode::Word);
console.print_text("䏿–‡ test");
console.flush().unwrap();
let lines = console.into_captured_lines();
let all_text: String = lines
.iter()
.map(|l| l.plain_text())
.collect::<Vec<_>>()
.join("");
assert!(all_text.contains("ä¸") && all_text.contains("æ–‡") && all_text.contains("test"));
}
#[test]
fn console_char_wrap() {
let sink = ConsoleSink::capture();
let mut console = Console::with_options(10, sink, WrapMode::Character);
console.print_text("HelloWorld123456");
console.flush().unwrap();
let lines = console.into_captured_lines();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].plain_text(), "HelloWorld");
assert_eq!(lines[1].plain_text(), "123456");
}
#[test]
fn console_no_wrap() {
let sink = ConsoleSink::capture();
let mut console = Console::with_options(10, sink, WrapMode::None);
console.print_text("This text is longer than 10 chars");
console.newline();
let lines = console.into_captured_lines();
assert_eq!(lines.len(), 1);
assert!(lines[0].width() > 10);
}
#[test]
fn console_rule() {
let sink = ConsoleSink::capture();
let mut console = Console::new(10, sink);
console.rule('-');
let lines = console.into_captured_lines();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].plain_text(), "----------");
}
#[test]
fn console_blank_line() {
let sink = ConsoleSink::capture();
let mut console = Console::new(80, sink);
console.print_text("Before");
console.newline();
console.blank_line();
console.print_text("After");
console.newline();
let output = console.into_captured();
assert_eq!(output, "Before\n\nAfter");
}
#[test]
fn console_line_count() {
let sink = ConsoleSink::capture();
let mut console = Console::new(80, sink);
assert_eq!(console.line_count(), 0);
console.println_text("Line 1");
assert_eq!(console.line_count(), 1);
console.println_text("Line 2");
assert_eq!(console.line_count(), 2);
}
#[test]
fn split_next_word_basic() {
assert_eq!(split_next_word("hello world"), ("hello ", "world"));
assert_eq!(split_next_word("hello"), ("hello", ""));
assert_eq!(split_next_word(" hello"), (" hello", ""));
assert_eq!(split_next_word(""), ("", ""));
}
#[test]
fn split_at_width_basic() {
assert_eq!(split_at_width("hello", 10), ("hello", ""));
assert_eq!(split_at_width("hello", 3), ("hel", "lo"));
assert_eq!(split_at_width("hello", 0), ("", "hello"));
}
#[test]
fn split_at_width_wide_chars() {
assert_eq!(split_at_width("䏿–‡", 2), ("ä¸", "æ–‡"));
assert_eq!(split_at_width("䏿–‡", 1), ("", "䏿–‡")); assert_eq!(split_at_width("䏿–‡", 4), ("䏿–‡", ""));
}
#[test]
fn console_wide_char_wrap() {
let sink = ConsoleSink::capture();
let mut console = Console::with_options(5, sink, WrapMode::Character);
console.print_text("䏿–‡æµ‹è¯•");
console.flush().unwrap();
let lines = console.into_captured_lines();
assert_eq!(lines[0].plain_text(), "䏿–‡");
assert_eq!(lines[1].plain_text(), "测试");
}
#[test]
fn console_segment_with_newline() {
let sink = ConsoleSink::capture();
let mut console = Console::new(80, sink);
console.print(Segment::text("Line 1"));
console.print(Segment::newline());
console.print(Segment::text("Line 2"));
console.newline();
let output = console.into_captured();
assert_eq!(output, "Line 1\nLine 2");
}
#[test]
fn console_clear_styles() {
let sink = ConsoleSink::capture();
let mut console = Console::new(80, sink);
console.push_style(Style::new().bold());
console.push_style(Style::new().italic());
assert_eq!(console.style_stack.len(), 2);
console.clear_styles();
assert_eq!(console.style_stack.len(), 0);
}
#[test]
fn console_current_style_merges() {
let sink = ConsoleSink::capture();
let mut console = Console::new(80, sink);
console.push_style(Style::new().fg(RED));
console.push_style(Style::new().bg(BLUE).bold());
let current = console.current_style();
assert_eq!(current.fg, Some(RED));
assert_eq!(current.bg, Some(BLUE));
assert!(current.has_attr(StyleFlags::BOLD));
}
#[test]
fn console_sink_capture_is_empty_initially() {
let sink = ConsoleSink::capture();
assert_eq!(sink.captured().unwrap().len(), 0);
}
#[test]
fn console_sink_writer_returns_none_for_captured() {
let sink = ConsoleSink::writer(Vec::<u8>::new());
assert!(sink.captured().is_none());
}
#[test]
fn console_sink_writer_output() {
let buf: Vec<u8> = Vec::new();
let sink = ConsoleSink::writer(buf);
let mut console = Console::new(80, sink);
console.println_text("hello");
console.flush().unwrap();
let output = console.into_captured();
assert_eq!(output, "");
}
#[derive(Clone, Default)]
struct SharedWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
impl Write for SharedWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0
.lock()
.map_err(|_| io::Error::other("poisoned test writer"))?
.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn console_sink_writer_sanitizes_escape_injection_payloads() {
let shared = SharedWriter::default();
let probe = shared.clone();
let sink = ConsoleSink::writer(shared);
let mut console = Console::new(80, sink);
console.println_text("safe\x1b]52;c;SGVsbG8=\x1b\\tail\u{009d}x");
console.flush().unwrap();
let bytes = probe.0.lock().unwrap().clone();
let output = String::from_utf8(bytes).unwrap();
assert!(
output.contains("safetailx\n"),
"visible payload should be preserved"
);
assert!(
!output.contains("52;c;SGVsbG8"),
"OSC payload must not be forwarded by writer sink"
);
assert!(
!output.contains('\u{009d}'),
"C1 controls must be stripped from writer sink output"
);
}
#[test]
fn console_sink_debug_format() {
let cap = ConsoleSink::capture();
let dbg = format!("{:?}", cap);
assert!(dbg.contains("Capture"));
let w = ConsoleSink::writer(Vec::<u8>::new());
let dbg = format!("{:?}", w);
assert!(dbg.contains("Writer"));
}
#[test]
fn captured_line_from_plain() {
let line = CapturedLine::from_plain("abc");
assert_eq!(line.plain_text(), "abc");
assert_eq!(line.width(), 3);
assert_eq!(line.segments.len(), 1);
assert_eq!(line.segments[0].style, Style::default());
}
#[test]
fn captured_line_width_with_wide_chars() {
let line = CapturedLine::from_plain("䏿–‡");
assert_eq!(line.width(), 4); }
#[test]
fn captured_line_empty() {
let line = CapturedLine::from_plain("");
assert_eq!(line.plain_text(), "");
assert_eq!(line.width(), 0);
}
#[test]
fn console_buffer_is_empty_when_only_empty_segments() {
let sink = ConsoleSink::capture();
let mut console = Console::with_options(80, sink, WrapMode::None);
console.print_text("");
console.newline();
let lines = console.into_captured_lines();
assert_eq!(lines.len(), 0);
}
#[test]
fn wrap_mode_default_is_word() {
assert_eq!(WrapMode::default(), WrapMode::Word);
}
#[test]
fn console_width_accessor() {
let console = Console::new(42, ConsoleSink::capture());
assert_eq!(console.width(), 42);
}
#[test]
fn console_with_options_sets_wrap_mode() {
let sink = ConsoleSink::capture();
let mut console = Console::with_options(10, sink, WrapMode::Character);
console.print_text("ABCDEFGHIJKLM");
console.flush().unwrap();
let lines = console.into_captured_lines();
assert_eq!(lines[0].plain_text(), "ABCDEFGHIJ"); }
#[test]
fn pop_style_on_empty_stack_returns_none() {
let sink = ConsoleSink::capture();
let mut console = Console::new(80, sink);
assert!(console.pop_style().is_none());
}
#[test]
fn pop_style_returns_pushed_style() {
let sink = ConsoleSink::capture();
let mut console = Console::new(80, sink);
let style = Style::new().bold();
console.push_style(style);
let popped = console.pop_style().unwrap();
assert!(popped.has_attr(StyleFlags::BOLD));
}
#[test]
fn current_style_empty_stack_is_default() {
let console = Console::new(80, ConsoleSink::capture());
assert_eq!(console.current_style(), Style::default());
}
#[test]
fn style_stack_deep_nesting() {
let sink = ConsoleSink::capture();
let mut console = Console::new(80, sink);
console.push_style(Style::new().fg(RED));
console.push_style(Style::new().bg(BLUE));
console.push_style(Style::new().bold());
console.push_style(Style::new().italic());
let merged = console.current_style();
assert_eq!(merged.fg, Some(RED));
assert_eq!(merged.bg, Some(BLUE));
assert!(merged.has_attr(StyleFlags::BOLD));
assert!(merged.has_attr(StyleFlags::ITALIC));
}
#[test]
fn println_styled_includes_newline() {
let sink = ConsoleSink::capture();
let mut console = Console::new(80, sink);
console.println_styled("styled line", Style::new().bold());
let lines = console.into_captured_lines();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].plain_text(), "styled line");
assert!(lines[0].segments[0].style.has_attr(StyleFlags::BOLD));
}
#[test]
fn print_styled_merges_with_stack() {
let sink = ConsoleSink::capture();
let mut console = Console::with_options(80, sink, WrapMode::None);
console.push_style(Style::new().fg(RED));
console.print_styled("text", Style::new().bold());
console.newline();
let lines = console.into_captured_lines();
assert_eq!(lines[0].segments[0].style.fg, Some(RED));
assert!(lines[0].segments[0].style.has_attr(StyleFlags::BOLD));
}
#[test]
fn println_text_creates_line() {
let sink = ConsoleSink::capture();
let mut console = Console::new(80, sink);
console.println_text("hello");
console.println_text("world");
let output = console.into_captured();
assert_eq!(output, "hello\nworld");
}
#[test]
fn blank_line_increments_line_count() {
let sink = ConsoleSink::capture();
let mut console = Console::new(80, sink);
console.blank_line();
assert_eq!(console.line_count(), 1);
console.blank_line();
assert_eq!(console.line_count(), 2);
}
#[test]
fn rule_uses_custom_character() {
let sink = ConsoleSink::capture();
let mut console = Console::new(5, sink);
console.rule('=');
let lines = console.into_captured_lines();
assert_eq!(lines[0].plain_text(), "=====");
}
#[test]
fn rule_with_pending_content_flushes_first() {
let sink = ConsoleSink::capture();
let mut console = Console::with_options(10, sink, WrapMode::None);
console.print_text("hello");
console.rule('-');
let lines = console.into_captured_lines();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].plain_text(), "hello");
assert_eq!(lines[1].plain_text(), "----------");
}
#[test]
fn into_captured_lines_returns_empty_for_writer() {
let sink = ConsoleSink::writer(Vec::<u8>::new());
let console = Console::new(80, sink);
let lines = console.into_captured_lines();
assert!(lines.is_empty());
}
#[test]
fn word_wrap_single_space() {
let sink = ConsoleSink::capture();
let mut console = Console::with_options(80, sink, WrapMode::Word);
console.print_text(" ");
console.flush().unwrap();
let lines = console.into_captured_lines();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].plain_text(), " ");
}
#[test]
fn word_wrap_exact_width_fit() {
let sink = ConsoleSink::capture();
let mut console = Console::with_options(5, sink, WrapMode::Word);
console.print_text("abcde");
console.flush().unwrap();
let lines = console.into_captured_lines();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].plain_text(), "abcde");
}
#[test]
fn word_wrap_width_1() {
let sink = ConsoleSink::capture();
let mut console = Console::with_options(1, sink, WrapMode::Word);
console.print_text("abc");
console.flush().unwrap();
let lines = console.into_captured_lines();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].plain_text(), "a");
assert_eq!(lines[1].plain_text(), "b");
assert_eq!(lines[2].plain_text(), "c");
}
#[test]
fn char_wrap_exact_multiple() {
let sink = ConsoleSink::capture();
let mut console = Console::with_options(3, sink, WrapMode::Character);
console.print_text("abcdef");
console.flush().unwrap();
let lines = console.into_captured_lines();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].plain_text(), "abc");
assert_eq!(lines[1].plain_text(), "def");
}
#[test]
fn char_wrap_wide_char_at_boundary() {
let sink = ConsoleSink::capture();
let mut console = Console::with_options(3, sink, WrapMode::Character);
console.print_text("aä¸b");
console.flush().unwrap();
let lines = console.into_captured_lines();
assert_eq!(lines[0].plain_text(), "aä¸");
assert_eq!(lines[1].plain_text(), "b");
}
#[test]
fn split_next_word_multiple_spaces() {
let (word, rest) = split_next_word(" hello world");
assert_eq!(word, " hello ");
assert_eq!(rest, " world");
}
#[test]
fn split_next_word_all_whitespace() {
let (word, rest) = split_next_word(" ");
assert_eq!(word, " ");
assert_eq!(rest, "");
}
#[test]
fn split_next_word_no_trailing_space() {
let (word, rest) = split_next_word("word");
assert_eq!(word, "word");
assert_eq!(rest, "");
}
#[test]
fn split_next_word_tab_as_whitespace() {
let (word, rest) = split_next_word("hello\tworld");
assert_eq!(word, "hello");
assert_eq!(rest, "\tworld");
}
#[test]
fn split_at_width_already_fits() {
assert_eq!(split_at_width("ab", 10), ("ab", ""));
}
#[test]
fn split_at_width_empty() {
assert_eq!(split_at_width("", 5), ("", ""));
}
#[test]
fn split_at_width_zero_width() {
assert_eq!(split_at_width("abc", 0), ("", "abc"));
}
#[test]
fn multiple_prints_same_line() {
let sink = ConsoleSink::capture();
let mut console = Console::with_options(80, sink, WrapMode::None);
console.print_text("Hello, ");
console.print_text("world!");
console.newline();
let lines = console.into_captured_lines();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].plain_text(), "Hello, world!");
assert_eq!(lines[0].segments.len(), 2);
}
#[test]
fn flush_writes_pending_content() {
let sink = ConsoleSink::capture();
let mut console = Console::new(80, sink);
console.print_text("pending");
assert_eq!(console.line_count(), 0);
console.flush().unwrap();
assert_eq!(console.line_count(), 1);
}
#[test]
fn into_captured_flushes_pending() {
let sink = ConsoleSink::capture();
let mut console = Console::new(80, sink);
console.print_text("no newline");
let output = console.into_captured();
assert_eq!(output, "no newline");
}
#[test]
fn into_captured_lines_flushes_pending() {
let sink = ConsoleSink::capture();
let mut console = Console::new(80, sink);
console.print_text("no newline");
let lines = console.into_captured_lines();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].plain_text(), "no newline");
}
#[test]
fn segment_style_supplements_stack() {
let sink = ConsoleSink::capture();
let mut console = Console::with_options(80, sink, WrapMode::None);
console.push_style(Style::new().fg(RED));
console.print(Segment::styled("text", Style::new().bold()));
console.newline();
let lines = console.into_captured_lines();
assert_eq!(lines[0].segments[0].style.fg, Some(RED));
assert!(lines[0].segments[0].style.has_attr(StyleFlags::BOLD));
}
}