use std::io::{self, Write};
use crossterm::{cursor::MoveTo, style::Print, QueueableCommand};
#[cfg(test)]
use ratatui::backend::Backend;
use ratatui::{
backend::CrosstermBackend,
buffer::Buffer,
layout::Rect,
style::Style,
Terminal,
};
#[cfg(test)]
use ratatui::{text::Line, widgets::{Paragraph, Widget}};
pub(crate) fn edge_scrub_positions(area: Rect) -> Vec<(u16, u16)> {
if area.width == 0 || area.height == 0 {
return Vec::new();
}
let mut positions = Vec::with_capacity(area.height as usize * if area.width > 1 { 2 } else { 1 });
for y in area.y..area.y.saturating_add(area.height) {
positions.push((area.x, y));
if area.width > 1 {
positions.push((area.x + area.width - 1, y));
}
}
positions
}
pub(crate) fn scrub_edge_columns_in_buffer(buf: &mut Buffer, area: Rect, style: Style) {
for (x, y) in edge_scrub_positions(area) {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.reset();
cell.set_style(style);
}
}
}
pub(crate) fn edge_scrub_area(size: Rect, protected_bottom_rows: u16) -> Option<Rect> {
let skip_top = 2u16;
let safe_height = size
.height
.saturating_sub(skip_top.saturating_add(protected_bottom_rows));
(safe_height > 0).then(|| Rect::new(0, skip_top, size.width, safe_height))
}
#[cfg(test)]
#[allow(dead_code)]
pub(crate) fn scrub_terminal_edges<B>(terminal: &mut Terminal<B>, style: Style) -> io::Result<()>
where
B: Backend<Error = io::Error>,
{
let size = terminal.size()?;
let area = Rect::new(0, 0, size.width, size.height);
scrub_edge_columns_in_buffer(terminal.current_buffer_mut(), area, style);
terminal.backend_mut().flush()
}
fn queue_edge_scrub<W: Write>(
backend: &mut CrosstermBackend<W>,
area: Rect,
) -> io::Result<()> {
for (x, y) in edge_scrub_positions(area) {
backend.queue(MoveTo(x, y))?;
backend.queue(Print(" "))?;
}
std::io::Write::flush(backend)
}
#[cfg_attr(test, allow(dead_code))]
pub(crate) fn scrub_crossterm_terminal_edges<W>(
terminal: &mut Terminal<CrosstermBackend<W>>,
caps: Option<&super::termcaps::TermCaps>,
protected_bottom_rows: u16,
style: Style,
) -> io::Result<()>
where
W: Write,
{
if !super::termcaps::edge_scrub_enabled(caps) {
return Ok(());
}
let size = terminal.size()?;
let Some(area) = edge_scrub_area(Rect::new(0, 0, size.width, size.height), protected_bottom_rows) else {
return Ok(());
};
queue_edge_scrub(terminal.backend_mut(), area)?;
scrub_edge_columns_in_buffer(terminal.current_buffer_mut(), area, style);
Ok(())
}
#[cfg(test)]
#[allow(dead_code)]
pub(crate) fn render_scrolled_lines(
buf: &mut Buffer,
area: Rect,
lines: &[Line<'static>],
style: Style,
) {
for y in area.y..area.y.saturating_add(area.height) {
for x in area.x..area.x.saturating_add(area.width) {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.reset();
cell.set_style(style);
}
}
}
Paragraph::new(lines.to_vec()).style(style).render(area, buf);
}
#[cfg(test)]
mod scrub_gate_tests {
use super::*;
use crate::tui::termcaps::{edge_scrub_enabled, TermCaps};
use ratatui::{TerminalOptions, Viewport};
use std::sync::{Arc, Mutex};
#[derive(Clone, Default)]
struct SharedSink(Arc<Mutex<Vec<u8>>>);
impl SharedSink {
fn bytes(&self) -> Vec<u8> {
self.0.lock().unwrap().clone()
}
}
impl Write for SharedSink {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn caps_tmux() -> TermCaps {
TermCaps { tmux: Some("3.4".to_string()), ..TermCaps::default() }
}
fn caps_no_tmux() -> TermCaps {
TermCaps { tmux: None, ..TermCaps::default() }
}
fn fixed_terminal(sink: SharedSink) -> Terminal<CrosstermBackend<SharedSink>> {
Terminal::with_options(
CrosstermBackend::new(sink),
TerminalOptions { viewport: Viewport::Fixed(Rect::new(0, 0, 80, 24)) },
)
.expect("fixed-viewport in-memory terminal construction is infallible")
}
#[test]
fn scrub_emits_zero_bytes_when_affirmatively_no_tmux() {
let sink = SharedSink::default();
let mut term = fixed_terminal(sink.clone());
scrub_crossterm_terminal_edges(&mut term, Some(&caps_no_tmux()), 6, Style::default())
.expect("gated-off scrub returns Ok without touching the terminal");
assert!(
sink.bytes().is_empty(),
"affirmatively no-tmux caps must emit ZERO scrub bytes"
);
}
#[test]
fn emission_core_writes_cup_and_space_bytes() {
let sink = SharedSink::default();
let mut backend = CrosstermBackend::new(sink.clone());
queue_edge_scrub(&mut backend, Rect::new(0, 2, 80, 16)).expect("queue emits");
let bytes = sink.bytes();
assert!(!bytes.is_empty(), "edge scrub must emit bytes");
assert!(
bytes.windows(2).any(|w| w == [0x1b, b'[']),
"scrub must emit CUP (ESC [) escapes"
);
assert!(bytes.contains(&b' '), "scrub must emit blanking spaces");
}
#[test]
fn gate_lets_tmux_and_unknown_reach_emission() {
assert!(edge_scrub_enabled(Some(&caps_tmux())), "tmux ⇒ scrub");
assert!(edge_scrub_enabled(None), "unknown ⇒ scrub (= today)");
assert!(!edge_scrub_enabled(Some(&caps_no_tmux())), "no-tmux ⇒ skip");
}
}