use super::backend::{TerminaBackend, TerminalBackend};
use super::hints::HintPanel;
use super::render::{LocalView, Notice, Palette, compose, paint};
use crate::view::{Frame, PaneModes};
use base64::Engine as _;
use ratatui_core::buffer::Buffer;
use std::io;
pub const MAX_CLIPBOARD_BYTES: usize = 1 << 20;
pub struct Screen<B: TerminalBackend> {
backend: B,
previous: Option<Buffer>,
clipboard_enabled: bool,
last_title: Option<String>,
previous_modes: Option<PaneModes>,
palette: Palette,
}
impl Screen<TerminaBackend> {
pub fn enter_default(clipboard_enabled: bool, palette: Palette) -> io::Result<Self> {
Self::enter(TerminaBackend::new()?, clipboard_enabled, palette)
}
}
#[cfg(test)]
impl Screen<super::backend::CaptureBackend> {
pub fn capture(rows: u16, cols: u16, clipboard_enabled: bool) -> io::Result<Self> {
Self::enter(
super::backend::CaptureBackend::new(rows, cols),
clipboard_enabled,
Palette::default(),
)
}
pub fn bytes(&self) -> &[u8] {
&self.backend.bytes
}
}
impl<B: TerminalBackend> Screen<B> {
pub fn enter(mut backend: B, clipboard_enabled: bool, palette: Palette) -> io::Result<Self> {
backend.enter_raw_mode()?;
if let Err(error) = backend.enter_alt_screen() {
let _ = backend.end_frame();
let _ = backend.leave_alt_screen();
let _ = backend.leave_raw_mode();
return Err(error);
}
backend.write_input_modes(b"\x1b[?1003h\x1b[?1006h")?;
backend.flush()?;
Ok(Self {
backend,
previous: None,
clipboard_enabled,
last_title: None,
previous_modes: None,
palette,
})
}
pub fn size(&self) -> io::Result<(u16, u16)> {
self.backend.size()
}
pub fn invalidate(&mut self) {
self.previous = None;
self.last_title = None;
self.previous_modes = None;
}
pub fn render(
&mut self,
frame: &Frame,
local: Option<&LocalView<'_>>,
panel: Option<&HintPanel>,
notice: Option<&Notice>,
) -> io::Result<()> {
self.emit_out_of_band(frame)?;
let (rows, cols) = self.backend.size()?;
let composed = compose(frame, local, panel, notice, &self.palette, rows, cols);
paint(
&mut self.backend,
self.previous.as_ref(),
&composed.buffer,
composed.cursor,
)?;
self.previous = Some(composed.buffer);
Ok(())
}
fn emit_out_of_band(&mut self, frame: &Frame) -> io::Result<()> {
let title = frame
.focused_pane()
.map(|pane| crate::view::printable(&pane.title, usize::MAX))
.unwrap_or_default();
if self.last_title.as_deref() != Some(title.as_str()) {
self.backend.set_window_title(&title)?;
self.last_title = Some(title);
}
let modes = frame
.focused_pane()
.map(|pane| pane.modes)
.unwrap_or_default();
let bytes = input_mode_bytes(self.previous_modes, modes);
if !bytes.is_empty() {
self.backend.write_input_modes(&bytes)?;
}
self.previous_modes = Some(modes);
Ok(())
}
pub fn copy_to_clipboard(&mut self, text: &str) -> io::Result<bool> {
if !self.clipboard_enabled {
return Ok(false);
}
let encoded = base64::engine::general_purpose::STANDARD.encode(text);
if encoded.is_empty() || encoded.len() > MAX_CLIPBOARD_BYTES {
return Ok(false);
}
self.backend.set_clipboard(&encoded)?;
self.backend.flush()?;
Ok(true)
}
pub fn clipboard_enabled(&self) -> bool {
self.clipboard_enabled
}
}
impl<B: TerminalBackend> Drop for Screen<B> {
fn drop(&mut self) {
let _ = self.backend.end_frame();
let _ = self.backend.leave_alt_screen();
let _ = self.backend.leave_raw_mode();
}
}
pub fn input_mode_bytes(previous: Option<PaneModes>, next: PaneModes) -> Vec<u8> {
let mut bytes = Vec::new();
let changed =
|get: fn(&PaneModes) -> bool| previous.is_none_or(|previous| get(&previous) != get(&next));
if changed(|modes| modes.application_keypad) {
bytes.extend_from_slice(if next.application_keypad {
b"\x1b="
} else {
b"\x1b>"
});
}
if changed(|modes| modes.application_cursor) {
bytes.extend_from_slice(if next.application_cursor {
b"\x1b[?1h"
} else {
b"\x1b[?1l"
});
}
if changed(|modes| modes.bracketed_paste) {
bytes.extend_from_slice(if next.bracketed_paste {
b"\x1b[?2004h"
} else {
b"\x1b[?2004l"
});
}
bytes
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::backend::CaptureBackend;
#[test]
fn input_modes_emit_only_changes() {
let mut modes = PaneModes::default();
let first = input_mode_bytes(None, modes);
assert!(first.starts_with(b"\x1b>"));
assert!(first.ends_with(b"\x1b[?2004l"));
modes.application_cursor = true;
assert_eq!(
input_mode_bytes(Some(PaneModes::default()), modes),
b"\x1b[?1h"
);
assert!(input_mode_bytes(Some(modes), modes).is_empty());
}
#[test]
fn screen_restores_terminal_on_drop_and_gates_clipboard() {
let mut screen = Screen::capture(4, 10, false).unwrap_or_else(|_| unreachable_screen());
assert!(screen.render(&Frame::default(), None, None, None).is_ok());
assert!(!screen.copy_to_clipboard("hi").unwrap_or(true));
let mut allowed = Screen::capture(4, 10, true).unwrap_or_else(|_| unreachable_screen());
assert!(allowed.copy_to_clipboard("hi").unwrap_or(false));
assert!(String::from_utf8_lossy(allowed.bytes()).contains("\x1b]52;c;aGk=\x07"));
let huge = "x".repeat(MAX_CLIPBOARD_BYTES);
assert!(!allowed.copy_to_clipboard(&huge).unwrap_or(true));
let bytes_before = screen.bytes().len();
drop(screen);
let _ = bytes_before;
}
fn unreachable_screen() -> Screen<CaptureBackend> {
Screen {
backend: CaptureBackend::new(1, 1),
previous: None,
clipboard_enabled: false,
last_title: None,
previous_modes: None,
palette: Palette::default(),
}
}
}