use std::fs::File;
use std::io::{self, BufWriter, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use bevy_ecs::prelude::*;
use bevy_log::error;
use crossterm::cursor::{Hide, MoveTo, Show};
use crossterm::event::{
DisableBracketedPaste, DisableFocusChange, EnableBracketedPaste, EnableFocusChange,
KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
};
use crossterm::terminal::{
BeginSynchronizedUpdate, Clear, ClearType, EndSynchronizedUpdate, EnterAlternateScreen,
LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use crossterm::{execute, queue};
use ratatui::backend::{CrosstermBackend, TestBackend};
use ratatui::buffer::Buffer;
use ratatui::layout::{Rect, Size};
use ratatui::{Frame, Terminal};
use super::input::TerminalInput;
use super::keys::KeyBinding;
use super::panel::{RequestRedraw, TerminalSize};
use super::theme::Theme;
use rig_ecs::tasks::Wake;
use rig_harness::harness_protocol::secret_variables;
use rig_harness::prelude::{Exit, ExitReason, Scratch};
#[derive(Component, Debug)]
pub struct RunInTerminal(pub Command);
#[derive(Component, Clone, Debug, PartialEq, Eq)]
pub struct TerminalExit(pub Result<ExitStatus, String>);
impl TerminalExit {
pub fn success(&self) -> bool {
self.0.as_ref().is_ok_and(ExitStatus::success)
}
}
#[derive(Resource, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct TerminalFocus(pub Option<bool>);
#[derive(Message, Clone, Debug, PartialEq, Eq)]
pub struct TerminalWrite(pub Vec<u8>);
#[derive(Message, Clone, Debug, PartialEq, Eq)]
pub struct CopyToClipboard(pub String);
impl CopyToClipboard {
fn escape(&self) -> Vec<u8> {
format!("\x1b]52;c;{}\x07", STANDARD.encode(&self.0)).into_bytes()
}
}
const FRAME_BUFFER: usize = 1 << 16;
#[derive(Resource)]
pub(crate) struct Tui {
pub(super) terminal: Terminal<CrosstermBackend<BufWriter<File>>>,
keep_screen: bool,
releases: bool,
screen: File,
}
const NOWHERE: &str = "/dev/null";
const KEYS: KeyboardEnhancementFlags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES;
const RELEASES: KeyboardEnhancementFlags = KEYS.union(KeyboardEnhancementFlags::REPORT_EVENT_TYPES);
impl Tui {
fn open(log: Option<&Path>) -> io::Result<Self> {
let screen = screen()?;
if let Some(log) = log {
redirect_output(log)?;
}
let copy = screen.try_clone()?;
let backend = CrosstermBackend::new(BufWriter::with_capacity(FRAME_BUFFER, screen));
let mut tui = Self {
terminal: Terminal::new(backend)?,
keep_screen: false,
releases: false,
screen: copy,
};
tui.enter()?;
Ok(tui)
}
fn enter(&mut self) -> io::Result<()> {
enable_raw_mode()?;
take_screen(&mut self.terminal, self.releases)
}
fn leave(&mut self, keep_screen: bool) -> io::Result<()> {
let backend = self.terminal.backend_mut();
if self.releases {
execute!(backend, PopKeyboardEnhancementFlags).ok();
}
execute!(backend, PopKeyboardEnhancementFlags).ok();
let pastes = execute!(backend, DisableBracketedPaste, DisableFocusChange);
let alternate = match keep_screen {
true => Ok(()),
false => execute!(backend, LeaveAlternateScreen),
};
let cursor = self.terminal.show_cursor();
pastes.and(alternate).and(cursor).and(disable_raw_mode())
}
pub(crate) fn begin_frame(&mut self) -> io::Result<()> {
queue!(self.terminal.backend_mut(), BeginSynchronizedUpdate, Hide)
}
pub(crate) fn end_frame(&mut self, cursor: Option<MoveTo>) -> io::Result<()> {
let backend = self.terminal.backend_mut();
if let Some(cursor) = cursor {
queue!(backend, cursor, Show)?;
}
execute!(backend, EndSynchronizedUpdate)
}
fn report_releases(&mut self, releases: bool) {
if self.releases == releases {
return;
}
self.releases = releases;
let backend = self.terminal.backend_mut();
let asked = if releases {
execute!(backend, PushKeyboardEnhancementFlags(RELEASES))
} else {
execute!(backend, PopKeyboardEnhancementFlags)
};
if let Err(failure) = asked {
error!("could not ask the terminal about key releases: {failure}");
}
}
}
fn take_screen<B>(terminal: &mut Terminal<B>, releases: bool) -> io::Result<()>
where
B: ratatui::backend::Backend<Error = io::Error> + Write,
{
let backend = terminal.backend_mut();
execute!(
backend,
EnterAlternateScreen,
EnableBracketedPaste,
EnableFocusChange,
Clear(ClearType::All)
)?;
execute!(backend, PushKeyboardEnhancementFlags(KEYS)).ok();
if releases {
execute!(backend, PushKeyboardEnhancementFlags(RELEASES)).ok();
}
terminal.hide_cursor()?;
terminal.swap_buffers();
terminal.swap_buffers();
Ok(())
}
#[derive(Resource)]
pub struct TuiTestScreen {
terminal: Terminal<TestBackend>,
written: Vec<u8>,
repainted: Vec<Rect>,
}
impl TuiTestScreen {
pub fn new(width: u16, height: u16) -> Self {
let Ok(terminal) = Terminal::new(TestBackend::new(width, height));
Self {
terminal,
written: Vec::new(),
repainted: Vec::new(),
}
}
pub fn resize(&mut self, width: u16, height: u16) {
self.terminal.backend_mut().resize(width, height);
}
pub fn size(&self) -> Size {
let area = self.buffer().area;
Size::new(area.width, area.height)
}
pub fn buffer(&self) -> &Buffer {
self.terminal.backend().buffer()
}
pub fn rows(&self) -> Vec<String> {
let buffer = self.buffer();
let area = buffer.area;
(area.top()..area.bottom())
.map(|y| {
let cells = (area.left()..area.right()).filter_map(|x| buffer.cell((x, y)));
cells.map(|cell| cell.symbol()).collect()
})
.collect()
}
pub fn written(&self) -> &[u8] {
&self.written
}
pub fn repainted(&self) -> &[Rect] {
&self.repainted
}
pub(crate) fn draw(&mut self, draw: impl FnOnce(&mut Frame), repaint: &[Rect]) {
let Ok(completed) = self.terminal.draw(draw);
let screen = completed.area;
let areas = repaint.iter().map(|area| area.intersection(screen));
self.repainted.extend(areas.filter(|area| !area.is_empty()));
}
}
pub(crate) fn has_screen(tui: Option<Res<Tui>>, test: Option<Res<TuiTestScreen>>) -> bool {
tui.is_some() || test.is_some()
}
impl Drop for Tui {
fn drop(&mut self) {
self.leave(self.keep_screen).ok();
}
}
pub(crate) fn open_terminal(
scratch: Option<Res<Scratch>>,
wake: Res<Wake>,
mut screen: ResMut<TerminalSize>,
mut commands: Commands,
) {
commands.insert_resource(Theme::of_terminal());
super::highlight::warm_up();
let log = scratch.map(|scratch| scratch.log().unwrap_or_else(|| PathBuf::from(NOWHERE)));
let opened =
Tui::open(log.as_deref()).and_then(|tui| Ok((tui, TerminalInput::start(wake.clone())?)));
match opened {
Ok((tui, input)) => {
if let Ok(size) = tui.terminal.size() {
screen.0 = Rect::new(0, 0, size.width, size.height);
}
commands.insert_resource(tui);
commands.insert_resource(input);
}
Err(failure) => {
error!("could not open the terminal: {failure}");
commands.trigger(Exit(ExitReason::Failed(1)));
}
}
}
pub(crate) fn keep_screen_on_reload(reason: Option<Res<ExitReason>>, tui: Option<ResMut<Tui>>) {
if let (Some(mut tui), Some(reason)) = (tui, reason)
&& matches!(*reason, ExitReason::Reload | ExitReason::Switch(_))
{
tui.keep_screen = true;
}
}
pub(crate) fn write_to_terminal(
(tui, test): (Option<ResMut<Tui>>, Option<ResMut<TuiTestScreen>>),
mut writes: MessageReader<TerminalWrite>,
mut copies: MessageReader<CopyToClipboard>,
) {
let bytes: Vec<u8> = writes
.read()
.flat_map(|write| write.0.iter().copied())
.chain(copies.read().flat_map(CopyToClipboard::escape))
.collect();
if bytes.is_empty() {
return;
}
let mut tui = match (tui, test) {
(Some(tui), _) => tui,
(None, Some(mut test)) => return test.written.extend(bytes),
(None, None) => return,
};
let backend = tui.terminal.backend_mut();
if let Err(failure) = backend.write_all(&bytes).and_then(|()| backend.flush()) {
error!("could not write to the terminal: {failure}");
}
}
pub(crate) fn report_releases(mut tui: ResMut<Tui>, bindings: Query<&KeyBinding>) {
let releases = bindings.iter().any(|binding| binding.releases);
tui.report_releases(releases);
}
pub(crate) fn run_in_terminal(world: &mut World) {
let mut runs = world.query_filtered::<Entity, (With<RunInTerminal>, Without<TerminalExit>)>();
let runs: Vec<Entity> = runs.iter(world).collect();
let input = world.remove_resource::<TerminalInput>();
let reading = input.is_some();
if let Some(input) = input {
input.stop();
}
let mut screen = None;
if let Some(mut tui) = world.get_resource_mut::<Tui>() {
match tui.leave(false) {
Ok(()) => screen = tui.screen.try_clone().ok(),
Err(failure) => error!("could not hand the terminal over: {failure}"),
}
}
for entity in runs {
let Some(mut run) = world.get_mut::<RunInTerminal>(entity) else {
continue;
};
let exit = run_with(&mut run.0, screen.as_ref());
world.entity_mut(entity).insert(TerminalExit(exit));
}
let size = world.get_resource_mut::<Tui>().and_then(|mut tui| {
if let Err(failure) = tui.enter() {
error!("could not take the terminal back: {failure}");
}
tui.terminal.size().ok()
});
if let Some(size) = size
&& let Some(mut screen) = world.get_resource_mut::<TerminalSize>()
{
screen.set_if_neq(TerminalSize(Rect::new(0, 0, size.width, size.height)));
}
let wake = world.get_resource::<Wake>().cloned();
if reading && let Some(wake) = &wake {
match TerminalInput::start(wake.clone()) {
Ok(input) => world.insert_resource(input),
Err(failure) => error!("could not read the terminal again: {failure}"),
}
}
world.write_message(RequestRedraw);
if let Some(wake) = wake {
wake.wake();
}
}
fn run_with(command: &mut Command, screen: Option<&File>) -> Result<ExitStatus, String> {
for secret in secret_variables() {
command.env_remove(secret);
}
if let Some(screen) = screen {
let output = screen.try_clone().map_err(|failure| failure.to_string())?;
let errors = screen.try_clone().map_err(|failure| failure.to_string())?;
command
.stdout(Stdio::from(output))
.stderr(Stdio::from(errors));
}
let program = command.get_program().to_string_lossy().into_owned();
command
.status()
.map_err(|failure| format!("could not run {program}: {failure}"))
}
#[cfg(unix)]
fn screen() -> io::Result<File> {
use std::os::fd::AsFd;
Ok(File::from(io::stdout().as_fd().try_clone_to_owned()?))
}
#[cfg(windows)]
fn screen() -> io::Result<File> {
use std::os::windows::io::AsHandle;
Ok(File::from(io::stdout().as_handle().try_clone_to_owned()?))
}
#[cfg(unix)]
fn redirect_output(log: &Path) -> io::Result<()> {
use std::os::fd::AsRawFd;
if let Some(directory) = log.parent() {
std::fs::create_dir_all(directory)?;
}
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log)?;
for target in [libc::STDOUT_FILENO, libc::STDERR_FILENO] {
if unsafe { libc::dup2(file.as_raw_fd(), target) } < 0 {
return Err(io::Error::last_os_error());
}
}
Ok(())
}
#[cfg(not(unix))]
fn redirect_output(_log: &Path) -> io::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests;