use std::collections::HashMap;
use std::io::{self, BufWriter, Read, Stdout, Write};
use std::time::{Duration, Instant};
use crossterm::event::{
DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
EnableFocusChange, EnableMouseCapture,
};
use crossterm::style::{
Attribute, Color as CtColor, Print, ResetColor, SetAttribute, SetBackgroundColor,
SetForegroundColor,
};
use crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate};
use crossterm::{cursor, execute, queue, terminal};
use unicode_width::UnicodeWidthStr;
use crate::buffer::{Buffer, KittyPlacement};
use crate::rect::Rect;
use crate::style::{Color, ColorDepth, Modifiers, Style};
#[inline]
fn sat_u16(v: u32) -> u16 {
v.min(u16::MAX as u32) as u16
}
pub(crate) struct KittyImageManager {
next_id: u32,
uploaded: HashMap<u64, u32>,
prev_placements: Vec<KittyPlacement>,
}
impl KittyImageManager {
pub fn new() -> Self {
Self {
next_id: 1,
uploaded: HashMap::new(),
prev_placements: Vec::new(),
}
}
pub fn flush(&mut self, stdout: &mut impl Write, current: &[KittyPlacement]) -> io::Result<()> {
if current == self.prev_placements.as_slice() {
return Ok(());
}
if !self.prev_placements.is_empty() {
let mut deleted_ids = std::collections::HashSet::new();
for p in &self.prev_placements {
if let Some(&img_id) = self.uploaded.get(&p.content_hash) {
if deleted_ids.insert(img_id) {
queue!(
stdout,
Print(format!("\x1b_Ga=d,d=i,i={},q=2\x1b\\", img_id))
)?;
}
}
}
}
for (idx, p) in current.iter().enumerate() {
let img_id = if let Some(&existing_id) = self.uploaded.get(&p.content_hash) {
existing_id
} else {
let id = self.next_id;
self.next_id += 1;
self.upload_image(stdout, id, p)?;
self.uploaded.insert(p.content_hash, id);
id
};
let pid = idx as u32 + 1;
self.place_image(stdout, img_id, pid, p)?;
}
let used_hashes: std::collections::HashSet<u64> =
current.iter().map(|p| p.content_hash).collect();
let stale: Vec<u64> = self
.uploaded
.keys()
.filter(|h| !used_hashes.contains(h))
.copied()
.collect();
for hash in stale {
if let Some(id) = self.uploaded.remove(&hash) {
queue!(stdout, Print(format!("\x1b_Ga=d,d=I,i={},q=2\x1b\\", id)))?;
}
}
self.prev_placements = current.to_vec();
Ok(())
}
fn upload_image(&self, stdout: &mut impl Write, id: u32, p: &KittyPlacement) -> io::Result<()> {
let (payload, compression) = compress_rgba(&p.rgba);
let encoded = base64_encode(&payload);
let chunks = split_base64(&encoded, 4096);
for (i, chunk) in chunks.iter().enumerate() {
let more = if i < chunks.len() - 1 { 1 } else { 0 };
if i == 0 {
queue!(
stdout,
Print(format!(
"\x1b_Ga=t,i={},f=32,{}s={},v={},q=2,m={};{}\x1b\\",
id, compression, p.src_width, p.src_height, more, chunk
))
)?;
} else {
queue!(stdout, Print(format!("\x1b_Gm={};{}\x1b\\", more, chunk)))?;
}
}
Ok(())
}
fn place_image(
&self,
stdout: &mut impl Write,
img_id: u32,
placement_id: u32,
p: &KittyPlacement,
) -> io::Result<()> {
queue!(stdout, cursor::MoveTo(sat_u16(p.x), sat_u16(p.y)))?;
let mut cmd = format!(
"\x1b_Ga=p,i={},p={},c={},r={},C=1,q=2",
img_id, placement_id, p.cols, p.rows
);
if p.crop_y > 0 || p.crop_h > 0 {
cmd.push_str(&format!(",y={}", p.crop_y));
if p.crop_h > 0 {
cmd.push_str(&format!(",h={}", p.crop_h));
}
}
cmd.push_str("\x1b\\");
queue!(stdout, Print(cmd))?;
Ok(())
}
pub fn delete_all(&self, stdout: &mut impl Write) -> io::Result<()> {
queue!(stdout, Print("\x1b_Ga=d,d=A,q=2\x1b\\"))
}
}
fn compress_rgba(data: &[u8]) -> (Vec<u8>, &'static str) {
#[cfg(feature = "kitty-compress")]
{
use flate2::write::ZlibEncoder;
use flate2::Compression;
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast());
if encoder.write_all(data).is_ok() {
if let Ok(compressed) = encoder.finish() {
if compressed.len() < data.len() {
return (compressed, "o=z,");
}
}
}
}
(data.to_vec(), "")
}
pub fn cell_pixel_size() -> (u32, u32) {
use std::sync::OnceLock;
static CACHED: OnceLock<(u32, u32)> = OnceLock::new();
*CACHED.get_or_init(|| detect_cell_pixel_size().unwrap_or((8, 16)))
}
fn detect_cell_pixel_size() -> Option<(u32, u32)> {
let mut stdout = io::stdout();
write!(stdout, "\x1b[16t").ok()?;
stdout.flush().ok()?;
let response = read_osc_response(Duration::from_millis(100))?;
let body = response.strip_prefix("\x1b[6;").or_else(|| {
let bytes = response.as_bytes();
if bytes.len() > 3 && bytes[0] == 0x9b && bytes[1] == b'6' && bytes[2] == b';' {
Some(&response[3..])
} else {
None
}
})?;
let body = body
.strip_suffix('t')
.or_else(|| body.strip_suffix("t\x1b"))?;
let mut parts = body.split(';');
let ch: u32 = parts.next()?.parse().ok()?;
let cw: u32 = parts.next()?.parse().ok()?;
if cw > 0 && ch > 0 {
Some((cw, ch))
} else {
None
}
}
fn split_base64(encoded: &str, chunk_size: usize) -> Vec<&str> {
let mut chunks = Vec::new();
let bytes = encoded.as_bytes();
let mut offset = 0;
while offset < bytes.len() {
let end = (offset + chunk_size).min(bytes.len());
chunks.push(&encoded[offset..end]);
offset = end;
}
if chunks.is_empty() {
chunks.push("");
}
chunks
}
pub(crate) struct Terminal {
stdout: BufWriter<Stdout>,
current: Buffer,
previous: Buffer,
cursor_visible: bool,
session: TerminalSessionGuard,
color_depth: ColorDepth,
pub(crate) theme_bg: Option<Color>,
kitty_mgr: KittyImageManager,
}
pub(crate) struct InlineTerminal {
stdout: BufWriter<Stdout>,
current: Buffer,
previous: Buffer,
cursor_visible: bool,
session: TerminalSessionGuard,
height: u32,
start_row: u16,
reserved: bool,
color_depth: ColorDepth,
pub(crate) theme_bg: Option<Color>,
kitty_mgr: KittyImageManager,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TerminalSessionMode {
Fullscreen,
Inline,
}
#[derive(Debug, Clone, Copy)]
struct TerminalSessionGuard {
mode: TerminalSessionMode,
mouse_enabled: bool,
kitty_keyboard: bool,
}
impl TerminalSessionGuard {
fn enter(
mode: TerminalSessionMode,
stdout: &mut impl Write,
mouse_enabled: bool,
kitty_keyboard: bool,
) -> io::Result<Self> {
let guard = Self {
mode,
mouse_enabled,
kitty_keyboard,
};
terminal::enable_raw_mode()?;
if let Err(err) = write_session_enter(stdout, &guard) {
guard.restore(stdout, false);
return Err(err);
}
Ok(guard)
}
fn restore(&self, stdout: &mut impl Write, inline_reserved: bool) {
if self.kitty_keyboard {
use crossterm::event::PopKeyboardEnhancementFlags;
let _ = execute!(stdout, PopKeyboardEnhancementFlags);
}
if self.mouse_enabled {
let _ = execute!(stdout, DisableMouseCapture);
}
let _ = execute!(stdout, DisableFocusChange);
let _ = write_session_cleanup(stdout, self.mode, inline_reserved);
let _ = terminal::disable_raw_mode();
}
}
impl Terminal {
pub fn new(mouse: bool, kitty_keyboard: bool, color_depth: ColorDepth) -> io::Result<Self> {
let (cols, rows) = terminal::size()?;
let area = Rect::new(0, 0, cols as u32, rows as u32);
let mut raw = io::stdout();
let session = TerminalSessionGuard::enter(
TerminalSessionMode::Fullscreen,
&mut raw,
mouse,
kitty_keyboard,
)?;
Ok(Self {
stdout: BufWriter::with_capacity(65536, raw),
current: Buffer::empty(area),
previous: Buffer::empty(area),
cursor_visible: false,
session,
color_depth,
theme_bg: None,
kitty_mgr: KittyImageManager::new(),
})
}
pub fn size(&self) -> (u32, u32) {
(self.current.area.width, self.current.area.height)
}
pub fn buffer_mut(&mut self) -> &mut Buffer {
&mut self.current
}
pub fn flush(&mut self) -> io::Result<()> {
if self.current.area.width < self.previous.area.width {
execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
}
queue!(self.stdout, BeginSynchronizedUpdate)?;
flush_buffer_diff(
&mut self.stdout,
&self.current,
&self.previous,
self.color_depth,
0,
)?;
self.kitty_mgr
.flush(&mut self.stdout, &self.current.kitty_placements)?;
flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, 0)?;
queue!(self.stdout, EndSynchronizedUpdate)?;
flush_cursor(
&mut self.stdout,
&mut self.cursor_visible,
self.current.cursor_pos(),
0,
None,
)?;
self.stdout.flush()?;
std::mem::swap(&mut self.current, &mut self.previous);
if let Some(bg) = self.theme_bg {
self.current.reset_with_bg(bg);
} else {
self.current.reset();
}
Ok(())
}
pub fn handle_resize(&mut self) -> io::Result<()> {
let (cols, rows) = terminal::size()?;
let area = Rect::new(0, 0, cols as u32, rows as u32);
self.current.resize(area);
self.previous.resize(area);
execute!(
self.stdout,
terminal::Clear(terminal::ClearType::All),
cursor::MoveTo(0, 0)
)?;
Ok(())
}
}
impl crate::Backend for Terminal {
fn size(&self) -> (u32, u32) {
Terminal::size(self)
}
fn buffer_mut(&mut self) -> &mut Buffer {
Terminal::buffer_mut(self)
}
fn flush(&mut self) -> io::Result<()> {
Terminal::flush(self)
}
}
impl InlineTerminal {
pub fn new(
height: u32,
mouse: bool,
kitty_keyboard: bool,
color_depth: ColorDepth,
) -> io::Result<Self> {
let (cols, _) = terminal::size()?;
let area = Rect::new(0, 0, cols as u32, height);
let mut raw = io::stdout();
let session = TerminalSessionGuard::enter(
TerminalSessionMode::Inline,
&mut raw,
mouse,
kitty_keyboard,
)?;
let (_, cursor_row) = match cursor::position() {
Ok(pos) => pos,
Err(err) => {
session.restore(&mut raw, false);
return Err(err);
}
};
Ok(Self {
stdout: BufWriter::with_capacity(65536, raw),
current: Buffer::empty(area),
previous: Buffer::empty(area),
cursor_visible: false,
session,
height,
start_row: cursor_row,
reserved: false,
color_depth,
theme_bg: None,
kitty_mgr: KittyImageManager::new(),
})
}
pub fn size(&self) -> (u32, u32) {
(self.current.area.width, self.current.area.height)
}
pub fn buffer_mut(&mut self) -> &mut Buffer {
&mut self.current
}
pub fn flush(&mut self) -> io::Result<()> {
if self.current.area.width < self.previous.area.width {
execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
}
queue!(self.stdout, BeginSynchronizedUpdate)?;
if !self.reserved {
queue!(self.stdout, cursor::MoveToColumn(0))?;
for _ in 0..self.height {
queue!(self.stdout, Print("\n"))?;
}
self.reserved = true;
let (_, rows) = terminal::size()?;
let bottom = self.start_row.saturating_add(sat_u16(self.height));
if bottom > rows {
self.start_row = rows.saturating_sub(sat_u16(self.height));
}
}
let row_offset = self.start_row as u32;
flush_buffer_diff(
&mut self.stdout,
&self.current,
&self.previous,
self.color_depth,
row_offset,
)?;
let adjusted: Vec<KittyPlacement> = self
.current
.kitty_placements
.iter()
.map(|p| {
let mut ap = p.clone();
ap.y += row_offset;
ap
})
.collect();
self.kitty_mgr.flush(&mut self.stdout, &adjusted)?;
flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, row_offset)?;
queue!(self.stdout, EndSynchronizedUpdate)?;
let fallback_row = row_offset + self.height.saturating_sub(1);
flush_cursor(
&mut self.stdout,
&mut self.cursor_visible,
self.current.cursor_pos(),
row_offset,
Some(fallback_row),
)?;
self.stdout.flush()?;
std::mem::swap(&mut self.current, &mut self.previous);
reset_current_buffer(&mut self.current, self.theme_bg);
Ok(())
}
pub fn handle_resize(&mut self) -> io::Result<()> {
let (cols, _) = terminal::size()?;
let area = Rect::new(0, 0, cols as u32, self.height);
self.current.resize(area);
self.previous.resize(area);
execute!(
self.stdout,
terminal::Clear(terminal::ClearType::All),
cursor::MoveTo(0, 0)
)?;
Ok(())
}
}
impl crate::Backend for InlineTerminal {
fn size(&self) -> (u32, u32) {
InlineTerminal::size(self)
}
fn buffer_mut(&mut self) -> &mut Buffer {
InlineTerminal::buffer_mut(self)
}
fn flush(&mut self) -> io::Result<()> {
InlineTerminal::flush(self)
}
}
impl Drop for Terminal {
fn drop(&mut self) {
let _ = self.kitty_mgr.delete_all(&mut self.stdout);
let _ = self.stdout.flush();
self.session.restore(&mut self.stdout, false);
}
}
impl Drop for InlineTerminal {
fn drop(&mut self) {
let _ = self.kitty_mgr.delete_all(&mut self.stdout);
let _ = self.stdout.flush();
self.session.restore(&mut self.stdout, self.reserved);
}
}
mod selection;
pub(crate) use selection::{apply_selection_overlay, extract_selection_text, SelectionState};
#[cfg(test)]
pub(crate) use selection::{find_innermost_rect, normalize_selection};
#[non_exhaustive]
#[cfg(feature = "crossterm")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorScheme {
Dark,
Light,
Unknown,
}
#[cfg(feature = "crossterm")]
fn read_osc_response(timeout: Duration) -> Option<String> {
let deadline = Instant::now() + timeout;
let mut stdin = io::stdin();
let mut bytes = Vec::new();
let mut buf = [0u8; 1];
while Instant::now() < deadline {
if !crossterm::event::poll(Duration::from_millis(10)).ok()? {
continue;
}
let read = stdin.read(&mut buf).ok()?;
if read == 0 {
continue;
}
bytes.push(buf[0]);
if buf[0] == b'\x07' {
break;
}
let len = bytes.len();
if len >= 2 && bytes[len - 2] == 0x1B && bytes[len - 1] == b'\\' {
break;
}
if bytes.len() >= 4096 {
break;
}
}
if bytes.is_empty() {
return None;
}
String::from_utf8(bytes).ok()
}
#[cfg(feature = "crossterm")]
pub fn detect_color_scheme() -> ColorScheme {
let mut stdout = io::stdout();
if write!(stdout, "\x1b]11;?\x07").is_err() {
return ColorScheme::Unknown;
}
if stdout.flush().is_err() {
return ColorScheme::Unknown;
}
let Some(response) = read_osc_response(Duration::from_millis(100)) else {
return ColorScheme::Unknown;
};
parse_osc11_response(&response)
}
#[cfg(feature = "crossterm")]
pub(crate) fn parse_osc11_response(response: &str) -> ColorScheme {
let Some(rgb_pos) = response.find("rgb:") else {
return ColorScheme::Unknown;
};
let payload = &response[rgb_pos + 4..];
let end = payload
.find(['\x07', '\x1b', '\r', '\n', ' ', '\t'])
.unwrap_or(payload.len());
let rgb = &payload[..end];
let mut channels = rgb.split('/');
let (Some(r), Some(g), Some(b), None) = (
channels.next(),
channels.next(),
channels.next(),
channels.next(),
) else {
return ColorScheme::Unknown;
};
fn parse_channel(channel: &str) -> Option<f64> {
if channel.is_empty() || channel.len() > 4 {
return None;
}
let value = u16::from_str_radix(channel, 16).ok()? as f64;
let max = ((1u32 << (channel.len() * 4)) - 1) as f64;
if max <= 0.0 {
return None;
}
Some((value / max).clamp(0.0, 1.0))
}
let (Some(r), Some(g), Some(b)) = (parse_channel(r), parse_channel(g), parse_channel(b)) else {
return ColorScheme::Unknown;
};
let luminance = 0.299 * r + 0.587 * g + 0.114 * b;
if luminance < 0.5 {
ColorScheme::Dark
} else {
ColorScheme::Light
}
}
fn base64_encode(input: &[u8]) -> String {
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
for chunk in input.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
let triple = (b0 << 16) | (b1 << 8) | b2;
out.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
out.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
out.push(if chunk.len() > 1 {
CHARS[((triple >> 6) & 0x3F) as usize] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
CHARS[(triple & 0x3F) as usize] as char
} else {
'='
});
}
out
}
pub(crate) fn copy_to_clipboard(w: &mut impl Write, text: &str) -> io::Result<()> {
let encoded = base64_encode(text.as_bytes());
write!(w, "\x1b]52;c;{encoded}\x1b\\")?;
w.flush()
}
#[cfg(feature = "crossterm")]
fn parse_osc52_response(response: &str) -> Option<String> {
let osc_pos = response.find("]52;")?;
let body = &response[osc_pos + 4..];
let semicolon = body.find(';')?;
let payload = &body[semicolon + 1..];
let end = payload
.find("\x1b\\")
.or_else(|| payload.find('\x07'))
.unwrap_or(payload.len());
let encoded = payload[..end].trim();
if encoded.is_empty() || encoded == "?" {
return None;
}
base64_decode(encoded)
}
#[cfg(feature = "crossterm")]
pub fn read_clipboard() -> Option<String> {
let mut stdout = io::stdout();
write!(stdout, "\x1b]52;c;?\x07").ok()?;
stdout.flush().ok()?;
let response = read_osc_response(Duration::from_millis(200))?;
parse_osc52_response(&response)
}
#[cfg(feature = "crossterm")]
fn base64_decode(input: &str) -> Option<String> {
let mut filtered: Vec<u8> = input
.bytes()
.filter(|b| !matches!(b, b' ' | b'\n' | b'\r' | b'\t'))
.collect();
match filtered.len() % 4 {
0 => {}
2 => filtered.extend_from_slice(b"=="),
3 => filtered.push(b'='),
_ => return None,
}
fn decode_val(b: u8) -> Option<u8> {
match b {
b'A'..=b'Z' => Some(b - b'A'),
b'a'..=b'z' => Some(b - b'a' + 26),
b'0'..=b'9' => Some(b - b'0' + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
let mut out = Vec::with_capacity((filtered.len() / 4) * 3);
for chunk in filtered.chunks_exact(4) {
let p2 = chunk[2] == b'=';
let p3 = chunk[3] == b'=';
if p2 && !p3 {
return None;
}
let v0 = decode_val(chunk[0])? as u32;
let v1 = decode_val(chunk[1])? as u32;
let v2 = if p2 { 0 } else { decode_val(chunk[2])? as u32 };
let v3 = if p3 { 0 } else { decode_val(chunk[3])? as u32 };
let triple = (v0 << 18) | (v1 << 12) | (v2 << 6) | v3;
out.push(((triple >> 16) & 0xFF) as u8);
if !p2 {
out.push(((triple >> 8) & 0xFF) as u8);
}
if !p3 {
out.push((triple & 0xFF) as u8);
}
}
String::from_utf8(out).ok()
}
#[allow(clippy::too_many_arguments)]
#[allow(unused_assignments)]
fn flush_buffer_diff(
stdout: &mut impl Write,
current: &Buffer,
previous: &Buffer,
color_depth: ColorDepth,
row_offset: u32,
) -> io::Result<()> {
let mut last_style = Style::new();
let mut first_style = true;
let mut active_link: Option<&str> = None;
let mut has_updates = false;
let mut last_cursor: Option<(u32, u32)> = None;
let mut run_buf = String::new();
let mut run_abs_y: u32 = 0;
let mut run_style: Style = Style::new();
let mut run_link: Option<&str> = None;
let mut run_next_col: u32 = 0;
let mut run_open = false;
macro_rules! flush_run {
($stdout:expr) => {
if run_open {
queue!($stdout, Print(&run_buf))?;
last_cursor = Some((run_next_col, run_abs_y));
run_buf.clear();
run_open = false;
}
};
}
for y in current.area.y..current.area.bottom() {
for x in current.area.x..current.area.right() {
let cell = current.get(x, y);
let prev = previous.get(x, y);
if cell == prev || cell.symbol.is_empty() {
flush_run!(stdout);
continue;
}
let abs_y = row_offset + y;
let cell_link = cell
.hyperlink
.as_deref()
.filter(|u| crate::buffer::is_valid_osc8_url(u));
let extends = run_open
&& run_abs_y == abs_y
&& run_next_col == x
&& run_style == cell.style
&& run_link == cell_link;
if !extends {
flush_run!(stdout);
has_updates = true;
let need_move = last_cursor.map_or(true, |(lx, ly)| lx != x || ly != abs_y);
if need_move {
queue!(stdout, cursor::MoveTo(sat_u16(x), sat_u16(abs_y)))?;
}
if cell.style != last_style {
if first_style {
queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
apply_style(stdout, &cell.style, color_depth)?;
first_style = false;
} else {
apply_style_delta(stdout, &last_style, &cell.style, color_depth)?;
}
last_style = cell.style;
}
if cell_link != active_link {
if let Some(url) = cell_link {
queue!(stdout, Print(format!("\x1b]8;;{url}\x07")))?;
} else {
queue!(stdout, Print("\x1b]8;;\x07"))?;
}
active_link = cell_link;
}
run_open = true;
run_abs_y = abs_y;
run_style = cell.style;
run_link = cell_link;
}
run_buf.push_str(&cell.symbol);
let char_width = UnicodeWidthStr::width(cell.symbol.as_str()).max(1) as u32;
if char_width > 1 && cell.symbol.chars().any(|c| c == '\u{FE0F}') {
run_buf.push(' ');
}
run_next_col = x + char_width;
}
flush_run!(stdout);
}
if has_updates {
if active_link.is_some() {
queue!(stdout, Print("\x1b]8;;\x07"))?;
}
queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
}
Ok(())
}
#[doc(hidden)]
pub fn __bench_flush_buffer_diff<W: Write>(
w: &mut W,
current: &Buffer,
previous: &Buffer,
color_depth: ColorDepth,
) -> io::Result<()> {
flush_buffer_diff(w, current, previous, color_depth, 0)
}
fn flush_raw_sequences(
stdout: &mut impl Write,
current: &Buffer,
previous: &Buffer,
row_offset: u32,
) -> io::Result<()> {
if current.raw_sequences == previous.raw_sequences {
return Ok(());
}
for (x, y, seq) in ¤t.raw_sequences {
queue!(
stdout,
cursor::MoveTo(sat_u16(*x), sat_u16(row_offset + *y)),
Print(seq)
)?;
}
Ok(())
}
fn flush_cursor(
stdout: &mut impl Write,
cursor_visible: &mut bool,
cursor_pos: Option<(u32, u32)>,
row_offset: u32,
fallback_row: Option<u32>,
) -> io::Result<()> {
match cursor_pos {
Some((cx, cy)) => {
if !*cursor_visible {
queue!(stdout, cursor::Show)?;
*cursor_visible = true;
}
queue!(
stdout,
cursor::MoveTo(sat_u16(cx), sat_u16(row_offset + cy))
)?;
}
None => {
if *cursor_visible {
queue!(stdout, cursor::Hide)?;
*cursor_visible = false;
}
if let Some(row) = fallback_row {
queue!(stdout, cursor::MoveTo(0, sat_u16(row)))?;
}
}
}
Ok(())
}
fn apply_style_delta(
w: &mut impl Write,
old: &Style,
new: &Style,
depth: ColorDepth,
) -> io::Result<()> {
if old.fg != new.fg {
match new.fg {
Some(fg) => queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?,
None => queue!(w, SetForegroundColor(CtColor::Reset))?,
}
}
if old.bg != new.bg {
match new.bg {
Some(bg) => queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?,
None => queue!(w, SetBackgroundColor(CtColor::Reset))?,
}
}
let removed = Modifiers(old.modifiers.0 & !new.modifiers.0);
let added = Modifiers(new.modifiers.0 & !old.modifiers.0);
if removed.contains(Modifiers::BOLD) || removed.contains(Modifiers::DIM) {
queue!(w, SetAttribute(Attribute::NormalIntensity))?;
if new.modifiers.contains(Modifiers::BOLD) {
queue!(w, SetAttribute(Attribute::Bold))?;
}
if new.modifiers.contains(Modifiers::DIM) {
queue!(w, SetAttribute(Attribute::Dim))?;
}
} else {
if added.contains(Modifiers::BOLD) {
queue!(w, SetAttribute(Attribute::Bold))?;
}
if added.contains(Modifiers::DIM) {
queue!(w, SetAttribute(Attribute::Dim))?;
}
}
if removed.contains(Modifiers::ITALIC) {
queue!(w, SetAttribute(Attribute::NoItalic))?;
}
if added.contains(Modifiers::ITALIC) {
queue!(w, SetAttribute(Attribute::Italic))?;
}
if removed.contains(Modifiers::UNDERLINE) {
queue!(w, SetAttribute(Attribute::NoUnderline))?;
}
if added.contains(Modifiers::UNDERLINE) {
queue!(w, SetAttribute(Attribute::Underlined))?;
}
if removed.contains(Modifiers::REVERSED) {
queue!(w, SetAttribute(Attribute::NoReverse))?;
}
if added.contains(Modifiers::REVERSED) {
queue!(w, SetAttribute(Attribute::Reverse))?;
}
if removed.contains(Modifiers::STRIKETHROUGH) {
queue!(w, SetAttribute(Attribute::NotCrossedOut))?;
}
if added.contains(Modifiers::STRIKETHROUGH) {
queue!(w, SetAttribute(Attribute::CrossedOut))?;
}
Ok(())
}
fn apply_style(w: &mut impl Write, style: &Style, depth: ColorDepth) -> io::Result<()> {
if let Some(fg) = style.fg {
queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?;
}
if let Some(bg) = style.bg {
queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?;
}
let m = style.modifiers;
if m.contains(Modifiers::BOLD) {
queue!(w, SetAttribute(Attribute::Bold))?;
}
if m.contains(Modifiers::DIM) {
queue!(w, SetAttribute(Attribute::Dim))?;
}
if m.contains(Modifiers::ITALIC) {
queue!(w, SetAttribute(Attribute::Italic))?;
}
if m.contains(Modifiers::UNDERLINE) {
queue!(w, SetAttribute(Attribute::Underlined))?;
}
if m.contains(Modifiers::REVERSED) {
queue!(w, SetAttribute(Attribute::Reverse))?;
}
if m.contains(Modifiers::STRIKETHROUGH) {
queue!(w, SetAttribute(Attribute::CrossedOut))?;
}
Ok(())
}
fn to_crossterm_color(color: Color, depth: ColorDepth) -> CtColor {
let color = color.downsampled(depth);
match color {
Color::Reset => CtColor::Reset,
Color::Black => CtColor::Black,
Color::Red => CtColor::DarkRed,
Color::Green => CtColor::DarkGreen,
Color::Yellow => CtColor::DarkYellow,
Color::Blue => CtColor::DarkBlue,
Color::Magenta => CtColor::DarkMagenta,
Color::Cyan => CtColor::DarkCyan,
Color::White => CtColor::White,
Color::DarkGray => CtColor::DarkGrey,
Color::LightRed => CtColor::Red,
Color::LightGreen => CtColor::Green,
Color::LightYellow => CtColor::Yellow,
Color::LightBlue => CtColor::Blue,
Color::LightMagenta => CtColor::Magenta,
Color::LightCyan => CtColor::Cyan,
Color::LightWhite => CtColor::White,
Color::Rgb(r, g, b) => CtColor::Rgb { r, g, b },
Color::Indexed(i) => CtColor::AnsiValue(i),
}
}
fn reset_current_buffer(buffer: &mut Buffer, theme_bg: Option<Color>) {
if let Some(bg) = theme_bg {
buffer.reset_with_bg(bg);
} else {
buffer.reset();
}
}
fn write_session_enter(stdout: &mut impl Write, session: &TerminalSessionGuard) -> io::Result<()> {
match session.mode {
TerminalSessionMode::Fullscreen => {
execute!(
stdout,
terminal::EnterAlternateScreen,
cursor::Hide,
EnableBracketedPaste
)?;
}
TerminalSessionMode::Inline => {
execute!(stdout, cursor::Hide, EnableBracketedPaste)?;
}
}
execute!(stdout, EnableFocusChange)?;
if session.mouse_enabled {
execute!(stdout, EnableMouseCapture)?;
}
if session.kitty_keyboard {
use crossterm::event::{KeyboardEnhancementFlags, PushKeyboardEnhancementFlags};
let _ = execute!(
stdout,
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
)
);
}
Ok(())
}
fn write_session_cleanup(
stdout: &mut impl Write,
mode: TerminalSessionMode,
inline_reserved: bool,
) -> io::Result<()> {
execute!(
stdout,
ResetColor,
SetAttribute(Attribute::Reset),
cursor::Show,
DisableBracketedPaste
)?;
match mode {
TerminalSessionMode::Fullscreen => {
execute!(stdout, terminal::LeaveAlternateScreen)?;
}
TerminalSessionMode::Inline => {
if inline_reserved {
execute!(
stdout,
cursor::MoveToColumn(0),
cursor::MoveDown(1),
cursor::MoveToColumn(0),
Print("\n")
)?;
} else {
execute!(stdout, Print("\n"))?;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn reset_current_buffer_applies_theme_background() {
let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 1));
reset_current_buffer(&mut buffer, Some(Color::Rgb(10, 20, 30)));
assert_eq!(buffer.get(0, 0).style.bg, Some(Color::Rgb(10, 20, 30)));
reset_current_buffer(&mut buffer, None);
assert_eq!(buffer.get(0, 0).style.bg, None);
}
#[test]
fn fullscreen_session_enter_writes_alt_screen_sequence() {
let session = TerminalSessionGuard {
mode: TerminalSessionMode::Fullscreen,
mouse_enabled: false,
kitty_keyboard: false,
};
let mut out = Vec::new();
write_session_enter(&mut out, &session).unwrap();
let output = String::from_utf8(out).unwrap();
assert!(output.contains("\u{1b}[?1049h"));
assert!(output.contains("\u{1b}[?25l"));
assert!(output.contains("\u{1b}[?2004h"));
}
#[test]
fn inline_session_enter_skips_alt_screen_sequence() {
let session = TerminalSessionGuard {
mode: TerminalSessionMode::Inline,
mouse_enabled: false,
kitty_keyboard: false,
};
let mut out = Vec::new();
write_session_enter(&mut out, &session).unwrap();
let output = String::from_utf8(out).unwrap();
assert!(!output.contains("\u{1b}[?1049h"));
assert!(output.contains("\u{1b}[?25l"));
assert!(output.contains("\u{1b}[?2004h"));
}
#[test]
fn fullscreen_session_cleanup_leaves_alt_screen() {
let mut out = Vec::new();
write_session_cleanup(&mut out, TerminalSessionMode::Fullscreen, false).unwrap();
let output = String::from_utf8(out).unwrap();
assert!(output.contains("\u{1b}[?1049l"));
assert!(output.contains("\u{1b}[?25h"));
assert!(output.contains("\u{1b}[?2004l"));
}
#[test]
fn inline_session_cleanup_keeps_normal_screen() {
let mut out = Vec::new();
write_session_cleanup(&mut out, TerminalSessionMode::Inline, false).unwrap();
let output = String::from_utf8(out).unwrap();
assert!(!output.contains("\u{1b}[?1049l"));
assert!(output.ends_with('\n'));
assert!(output.contains("\u{1b}[?25h"));
assert!(output.contains("\u{1b}[?2004l"));
}
#[test]
fn base64_encode_empty() {
assert_eq!(base64_encode(b""), "");
}
#[test]
fn base64_encode_hello() {
assert_eq!(base64_encode(b"Hello"), "SGVsbG8=");
}
#[test]
fn base64_encode_padding() {
assert_eq!(base64_encode(b"a"), "YQ==");
assert_eq!(base64_encode(b"ab"), "YWI=");
assert_eq!(base64_encode(b"abc"), "YWJj");
}
#[test]
fn base64_encode_unicode() {
assert_eq!(base64_encode("한글".as_bytes()), "7ZWc6riA");
}
#[cfg(feature = "crossterm")]
#[test]
fn parse_osc11_response_dark_and_light() {
assert_eq!(
parse_osc11_response("\x1b]11;rgb:0000/0000/0000\x1b\\"),
ColorScheme::Dark
);
assert_eq!(
parse_osc11_response("\x1b]11;rgb:ffff/ffff/ffff\x07"),
ColorScheme::Light
);
}
#[cfg(feature = "crossterm")]
#[test]
fn base64_decode_round_trip_hello() {
let encoded = base64_encode("hello".as_bytes());
assert_eq!(base64_decode(&encoded), Some("hello".to_string()));
}
#[cfg(feature = "crossterm")]
#[test]
fn color_scheme_equality() {
assert_eq!(ColorScheme::Dark, ColorScheme::Dark);
assert_ne!(ColorScheme::Dark, ColorScheme::Light);
assert_eq!(ColorScheme::Unknown, ColorScheme::Unknown);
}
fn pair(r: Rect) -> (Rect, Rect) {
(r, r)
}
#[test]
fn find_innermost_rect_picks_smallest() {
let rects = vec![
pair(Rect::new(0, 0, 80, 24)),
pair(Rect::new(5, 2, 30, 10)),
pair(Rect::new(10, 4, 10, 5)),
];
let result = find_innermost_rect(&rects, 12, 5);
assert_eq!(result, Some(Rect::new(10, 4, 10, 5)));
}
#[test]
fn find_innermost_rect_no_match() {
let rects = vec![pair(Rect::new(10, 10, 5, 5))];
assert_eq!(find_innermost_rect(&rects, 0, 0), None);
}
#[test]
fn find_innermost_rect_empty() {
assert_eq!(find_innermost_rect(&[], 5, 5), None);
}
#[test]
fn find_innermost_rect_returns_content_rect() {
let rects = vec![
(Rect::new(0, 0, 80, 24), Rect::new(1, 1, 78, 22)),
(Rect::new(5, 2, 30, 10), Rect::new(6, 3, 28, 8)),
];
let result = find_innermost_rect(&rects, 10, 5);
assert_eq!(result, Some(Rect::new(6, 3, 28, 8)));
}
#[test]
fn normalize_selection_already_ordered() {
let (s, e) = normalize_selection((2, 1), (5, 3));
assert_eq!(s, (2, 1));
assert_eq!(e, (5, 3));
}
#[test]
fn normalize_selection_reversed() {
let (s, e) = normalize_selection((5, 3), (2, 1));
assert_eq!(s, (2, 1));
assert_eq!(e, (5, 3));
}
#[test]
fn normalize_selection_same_row() {
let (s, e) = normalize_selection((10, 5), (3, 5));
assert_eq!(s, (3, 5));
assert_eq!(e, (10, 5));
}
#[test]
fn selection_state_mouse_down_finds_rect() {
let hit_map = vec![pair(Rect::new(0, 0, 80, 24)), pair(Rect::new(5, 2, 20, 10))];
let mut sel = SelectionState::default();
sel.mouse_down(10, 5, &hit_map);
assert_eq!(sel.anchor, Some((10, 5)));
assert_eq!(sel.current, Some((10, 5)));
assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 20, 10)));
assert!(!sel.active);
}
#[test]
fn selection_state_drag_activates() {
let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
let mut sel = SelectionState {
anchor: Some((10, 5)),
current: Some((10, 5)),
widget_rect: Some(Rect::new(0, 0, 80, 24)),
..Default::default()
};
sel.mouse_drag(10, 5, &hit_map);
assert!(!sel.active, "no movement = not active");
sel.mouse_drag(11, 5, &hit_map);
assert!(!sel.active, "1 cell horizontal = not active yet");
sel.mouse_drag(13, 5, &hit_map);
assert!(sel.active, ">1 cell horizontal = active");
}
#[test]
fn selection_state_drag_vertical_activates() {
let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
let mut sel = SelectionState {
anchor: Some((10, 5)),
current: Some((10, 5)),
widget_rect: Some(Rect::new(0, 0, 80, 24)),
..Default::default()
};
sel.mouse_drag(10, 6, &hit_map);
assert!(sel.active, "any vertical movement = active");
}
#[test]
fn selection_state_drag_expands_widget_rect() {
let hit_map = vec![
pair(Rect::new(0, 0, 80, 24)),
pair(Rect::new(5, 2, 30, 10)),
pair(Rect::new(5, 2, 30, 3)),
];
let mut sel = SelectionState {
anchor: Some((10, 3)),
current: Some((10, 3)),
widget_rect: Some(Rect::new(5, 2, 30, 3)),
..Default::default()
};
sel.mouse_drag(10, 6, &hit_map);
assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 30, 10)));
}
#[test]
fn selection_state_clear_resets() {
let mut sel = SelectionState {
anchor: Some((1, 2)),
current: Some((3, 4)),
widget_rect: Some(Rect::new(0, 0, 10, 10)),
active: true,
};
sel.clear();
assert_eq!(sel.anchor, None);
assert_eq!(sel.current, None);
assert_eq!(sel.widget_rect, None);
assert!(!sel.active);
}
#[test]
fn extract_selection_text_single_line() {
let area = Rect::new(0, 0, 20, 5);
let mut buf = Buffer::empty(area);
buf.set_string(0, 0, "Hello World", Style::default());
let sel = SelectionState {
anchor: Some((0, 0)),
current: Some((4, 0)),
widget_rect: Some(area),
active: true,
};
let text = extract_selection_text(&buf, &sel, &[]);
assert_eq!(text, "Hello");
}
#[test]
fn extract_selection_text_multi_line() {
let area = Rect::new(0, 0, 20, 5);
let mut buf = Buffer::empty(area);
buf.set_string(0, 0, "Line one", Style::default());
buf.set_string(0, 1, "Line two", Style::default());
buf.set_string(0, 2, "Line three", Style::default());
let sel = SelectionState {
anchor: Some((5, 0)),
current: Some((3, 2)),
widget_rect: Some(area),
active: true,
};
let text = extract_selection_text(&buf, &sel, &[]);
assert_eq!(text, "one\nLine two\nLine");
}
#[test]
fn extract_selection_text_clamped_to_widget() {
let area = Rect::new(0, 0, 40, 10);
let widget = Rect::new(5, 2, 10, 3);
let mut buf = Buffer::empty(area);
buf.set_string(5, 2, "ABCDEFGHIJ", Style::default());
buf.set_string(5, 3, "KLMNOPQRST", Style::default());
let sel = SelectionState {
anchor: Some((3, 1)),
current: Some((20, 5)),
widget_rect: Some(widget),
active: true,
};
let text = extract_selection_text(&buf, &sel, &[]);
assert_eq!(text, "ABCDEFGHIJ\nKLMNOPQRST");
}
#[test]
fn extract_selection_text_inactive_returns_empty() {
let area = Rect::new(0, 0, 10, 5);
let buf = Buffer::empty(area);
let sel = SelectionState {
anchor: Some((0, 0)),
current: Some((5, 2)),
widget_rect: Some(area),
active: false,
};
assert_eq!(extract_selection_text(&buf, &sel, &[]), "");
}
#[test]
fn apply_selection_overlay_reverses_cells() {
let area = Rect::new(0, 0, 10, 3);
let mut buf = Buffer::empty(area);
buf.set_string(0, 0, "ABCDE", Style::default());
let sel = SelectionState {
anchor: Some((1, 0)),
current: Some((3, 0)),
widget_rect: Some(area),
active: true,
};
apply_selection_overlay(&mut buf, &sel, &[]);
assert!(!buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED));
assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
assert!(!buf.get(4, 0).style.modifiers.contains(Modifiers::REVERSED));
}
#[test]
fn extract_selection_text_skips_border_cells() {
let area = Rect::new(0, 0, 40, 5);
let mut buf = Buffer::empty(area);
buf.set_string(0, 0, "â•", Style::default());
buf.set_string(0, 1, "│", Style::default());
buf.set_string(0, 2, "│", Style::default());
buf.set_string(0, 3, "│", Style::default());
buf.set_string(0, 4, "â•°", Style::default());
buf.set_string(19, 0, "â•®", Style::default());
buf.set_string(19, 1, "│", Style::default());
buf.set_string(19, 2, "│", Style::default());
buf.set_string(19, 3, "│", Style::default());
buf.set_string(19, 4, "╯", Style::default());
buf.set_string(20, 0, "â•", Style::default());
buf.set_string(20, 1, "│", Style::default());
buf.set_string(20, 2, "│", Style::default());
buf.set_string(20, 3, "│", Style::default());
buf.set_string(20, 4, "â•°", Style::default());
buf.set_string(39, 0, "â•®", Style::default());
buf.set_string(39, 1, "│", Style::default());
buf.set_string(39, 2, "│", Style::default());
buf.set_string(39, 3, "│", Style::default());
buf.set_string(39, 4, "╯", Style::default());
buf.set_string(1, 1, "Hello Col1", Style::default());
buf.set_string(1, 2, "Line2 Col1", Style::default());
buf.set_string(21, 1, "Hello Col2", Style::default());
buf.set_string(21, 2, "Line2 Col2", Style::default());
let content_map = vec![
(Rect::new(0, 0, 20, 5), Rect::new(1, 1, 18, 3)),
(Rect::new(20, 0, 20, 5), Rect::new(21, 1, 18, 3)),
];
let sel = SelectionState {
anchor: Some((0, 1)),
current: Some((39, 2)),
widget_rect: Some(area),
active: true,
};
let text = extract_selection_text(&buf, &sel, &content_map);
assert!(!text.contains('│'), "Border char │ found in: {text}");
assert!(!text.contains('â•'), "Border char â• found in: {text}");
assert!(!text.contains('â•®'), "Border char â•® found in: {text}");
assert!(
text.contains("Hello Col1"),
"Missing Col1 content in: {text}"
);
assert!(
text.contains("Hello Col2"),
"Missing Col2 content in: {text}"
);
assert!(text.contains("Line2 Col1"), "Missing Col1 line2 in: {text}");
assert!(text.contains("Line2 Col2"), "Missing Col2 line2 in: {text}");
}
#[test]
fn apply_selection_overlay_skips_border_cells() {
let area = Rect::new(0, 0, 20, 3);
let mut buf = Buffer::empty(area);
buf.set_string(0, 0, "│", Style::default());
buf.set_string(1, 0, "ABC", Style::default());
buf.set_string(19, 0, "│", Style::default());
let content_map = vec![(Rect::new(0, 0, 20, 3), Rect::new(1, 0, 18, 3))];
let sel = SelectionState {
anchor: Some((0, 0)),
current: Some((19, 0)),
widget_rect: Some(area),
active: true,
};
apply_selection_overlay(&mut buf, &sel, &content_map);
assert!(
!buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED),
"Left border cell should not be reversed"
);
assert!(
!buf.get(19, 0).style.modifiers.contains(Modifiers::REVERSED),
"Right border cell should not be reversed"
);
assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
}
#[test]
fn copy_to_clipboard_writes_osc52() {
let mut output: Vec<u8> = Vec::new();
copy_to_clipboard(&mut output, "test").unwrap();
let s = String::from_utf8(output).unwrap();
assert!(s.starts_with("\x1b]52;c;"));
assert!(s.ends_with("\x1b\\"));
assert!(s.contains(&base64_encode(b"test")));
}
fn count_move_tos(s: &str) -> usize {
let bytes = s.as_bytes();
let mut count = 0;
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == 0x1b && bytes[i + 1] == b'[' {
let mut j = i + 2;
while j < bytes.len() && !(0x40..=0x7e).contains(&bytes[j]) {
j += 1;
}
if j < bytes.len() && bytes[j] == b'H' {
count += 1;
}
i = j + 1;
} else {
i += 1;
}
}
count
}
#[test]
fn flush_coalesces_consecutive_same_style_cells_into_one_run() {
let area = Rect::new(0, 0, 20, 1);
let mut current = Buffer::empty(area);
let previous = Buffer::empty(area);
let style = Style::new().fg(Color::Red);
for x in 0..10u32 {
let cell = current.get_mut(x, 0);
cell.set_char('X');
cell.set_style(style);
}
let mut out: Vec<u8> = Vec::new();
flush_buffer_diff(&mut out, ¤t, &previous, ColorDepth::TrueColor, 0).unwrap();
let s = String::from_utf8(out).unwrap();
assert_eq!(
count_move_tos(&s),
1,
"expected 1 MoveTo for a coalesced run, got {} in {:?}",
count_move_tos(&s),
s
);
assert!(
s.contains("XXXXXXXXXX"),
"expected contiguous run 'XXXXXXXXXX' in {:?}",
s
);
}
#[test]
fn flush_breaks_run_on_style_change() {
let area = Rect::new(0, 0, 20, 1);
let mut current = Buffer::empty(area);
let previous = Buffer::empty(area);
let red = Style::new().fg(Color::Red);
let blue = Style::new().fg(Color::Blue);
for x in 0..5u32 {
let cell = current.get_mut(x, 0);
cell.set_char('R');
cell.set_style(red);
}
for x in 5..10u32 {
let cell = current.get_mut(x, 0);
cell.set_char('B');
cell.set_style(blue);
}
let mut out: Vec<u8> = Vec::new();
flush_buffer_diff(&mut out, ¤t, &previous, ColorDepth::TrueColor, 0).unwrap();
let s = String::from_utf8(out).unwrap();
let moves = count_move_tos(&s);
assert!(
moves <= 2,
"expected at most 2 MoveTos across a style boundary, got {} in {:?}",
moves,
s
);
assert!(s.contains("RRRRR"), "missing 'RRRRR' run in {:?}", s);
assert!(s.contains("BBBBB"), "missing 'BBBBB' run in {:?}", s);
}
#[test]
fn flush_breaks_run_on_column_gap() {
let area = Rect::new(0, 0, 20, 1);
let mut current = Buffer::empty(area);
let previous = Buffer::empty(area);
let style = Style::new().fg(Color::Green);
for x in 0..3u32 {
current.get_mut(x, 0).set_char('A').set_style(style);
}
for x in 6..9u32 {
current.get_mut(x, 0).set_char('B').set_style(style);
}
let mut out: Vec<u8> = Vec::new();
flush_buffer_diff(&mut out, ¤t, &previous, ColorDepth::TrueColor, 0).unwrap();
let s = String::from_utf8(out).unwrap();
assert_eq!(
count_move_tos(&s),
2,
"expected 2 MoveTos across a column gap, got {} in {:?}",
count_move_tos(&s),
s
);
assert!(s.contains("AAA"), "missing 'AAA' run in {:?}", s);
assert!(s.contains("BBB"), "missing 'BBB' run in {:?}", s);
}
#[test]
fn bufwriter_output_identical_to_direct_write() {
let area = Rect::new(0, 0, 5, 1);
let mut current = Buffer::empty(area);
let previous = Buffer::empty(area);
let style = Style::new().fg(Color::Rgb(255, 128, 0));
for x in 0..5u32 {
current.get_mut(x, 0).set_char('X').set_style(style);
}
let mut direct: Vec<u8> = Vec::new();
flush_buffer_diff(&mut direct, ¤t, &previous, ColorDepth::TrueColor, 0).unwrap();
let mut buffered: BufWriter<Vec<u8>> = BufWriter::with_capacity(65536, Vec::new());
flush_buffer_diff(&mut buffered, ¤t, &previous, ColorDepth::TrueColor, 0).unwrap();
buffered.flush().unwrap();
let via_buf = buffered.into_inner().unwrap();
assert_eq!(
direct, via_buf,
"BufWriter output must be byte-for-byte identical to direct write"
);
}
#[test]
fn bufwriter_coalesces_writes_into_single_flush() {
#[derive(Debug)]
struct CountingWriter {
buf: Vec<u8>,
write_call_count: usize,
}
impl Write for CountingWriter {
fn write(&mut self, data: &[u8]) -> io::Result<usize> {
self.write_call_count += 1;
self.buf.extend_from_slice(data);
Ok(data.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
let area = Rect::new(0, 0, 10, 1);
let mut current = Buffer::empty(area);
let previous = Buffer::empty(area);
for x in 0..10u32 {
let color = if x % 2 == 0 {
Color::Rgb(255, 0, 0)
} else {
Color::Rgb(0, 255, 0)
};
current
.get_mut(x, 0)
.set_char('Z')
.set_style(Style::new().fg(color));
}
let sink = CountingWriter {
buf: Vec::new(),
write_call_count: 0,
};
let mut bw = BufWriter::with_capacity(65536, sink);
flush_buffer_diff(&mut bw, ¤t, &previous, ColorDepth::TrueColor, 0).unwrap();
bw.flush().unwrap();
let inner = bw.into_inner().unwrap();
assert_eq!(
inner.write_call_count, 1,
"expected 1 write syscall to sink, got {}",
inner.write_call_count
);
}
}