1use std::fs::File;
9use std::io::{self, BufWriter, Write};
10use std::path::{Path, PathBuf};
11use std::process::{Command, ExitStatus, Stdio};
12
13use base64::Engine;
14use base64::engine::general_purpose::STANDARD;
15use bevy_ecs::prelude::*;
16use bevy_log::error;
17use crossterm::cursor::{Hide, MoveTo, Show};
18use crossterm::event::{
19 DisableBracketedPaste, DisableFocusChange, EnableBracketedPaste, EnableFocusChange,
20 KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
21};
22use crossterm::terminal::{
23 BeginSynchronizedUpdate, Clear, ClearType, EndSynchronizedUpdate, EnterAlternateScreen,
24 LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
25};
26use crossterm::{execute, queue};
27use ratatui::backend::{CrosstermBackend, TestBackend};
28use ratatui::buffer::Buffer;
29use ratatui::layout::{Rect, Size};
30use ratatui::{Frame, Terminal};
31
32use super::input::TerminalInput;
33use super::keys::KeyBinding;
34use super::panel::{RequestRedraw, TerminalSize};
35use super::theme::Theme;
36use rig_ecs::tasks::Wake;
37use rig_harness::harness_protocol::secret_variables;
38use rig_harness::prelude::{Exit, ExitReason, Scratch};
39
40#[derive(Component, Debug)]
52pub struct RunInTerminal(pub Command);
53
54#[derive(Component, Clone, Debug, PartialEq, Eq)]
57pub struct TerminalExit(pub Result<ExitStatus, String>);
58
59impl TerminalExit {
60 pub fn success(&self) -> bool {
62 self.0.as_ref().is_ok_and(ExitStatus::success)
63 }
64}
65
66#[derive(Resource, Clone, Copy, Debug, Default, PartialEq, Eq)]
70pub struct TerminalFocus(pub Option<bool>);
71
72#[derive(Message, Clone, Debug, PartialEq, Eq)]
78pub struct TerminalWrite(pub Vec<u8>);
79
80#[derive(Message, Clone, Debug, PartialEq, Eq)]
85pub struct CopyToClipboard(pub String);
86
87impl CopyToClipboard {
88 fn escape(&self) -> Vec<u8> {
90 format!("\x1b]52;c;{}\x07", STANDARD.encode(&self.0)).into_bytes()
91 }
92}
93
94const FRAME_BUFFER: usize = 1 << 16;
97
98#[derive(Resource)]
103pub(crate) struct Tui {
104 pub(super) terminal: Terminal<CrosstermBackend<BufWriter<File>>>,
105 keep_screen: bool,
106 releases: bool,
108 screen: File,
110}
111
112const NOWHERE: &str = "/dev/null";
114
115const KEYS: KeyboardEnhancementFlags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES;
118
119const RELEASES: KeyboardEnhancementFlags = KEYS.union(KeyboardEnhancementFlags::REPORT_EVENT_TYPES);
122
123impl Tui {
124 fn open(log: Option<&Path>) -> io::Result<Self> {
128 let screen = screen()?;
129 if let Some(log) = log {
130 redirect_output(log)?;
131 }
132 let copy = screen.try_clone()?;
133 let backend = CrosstermBackend::new(BufWriter::with_capacity(FRAME_BUFFER, screen));
134 let mut tui = Self {
135 terminal: Terminal::new(backend)?,
136 keep_screen: false,
137 releases: false,
138 screen: copy,
139 };
140 tui.enter()?;
141 Ok(tui)
142 }
143
144 fn enter(&mut self) -> io::Result<()> {
146 enable_raw_mode()?;
147 take_screen(&mut self.terminal, self.releases)
148 }
149
150 fn leave(&mut self, keep_screen: bool) -> io::Result<()> {
153 let backend = self.terminal.backend_mut();
154 if self.releases {
155 execute!(backend, PopKeyboardEnhancementFlags).ok();
156 }
157 execute!(backend, PopKeyboardEnhancementFlags).ok();
158 let pastes = execute!(backend, DisableBracketedPaste, DisableFocusChange);
160 let alternate = match keep_screen {
161 true => Ok(()),
162 false => execute!(backend, LeaveAlternateScreen),
163 };
164 let cursor = self.terminal.show_cursor();
165 pastes.and(alternate).and(cursor).and(disable_raw_mode())
166 }
167
168 pub(crate) fn begin_frame(&mut self) -> io::Result<()> {
171 queue!(self.terminal.backend_mut(), BeginSynchronizedUpdate, Hide)
172 }
173
174 pub(crate) fn end_frame(&mut self, cursor: Option<MoveTo>) -> io::Result<()> {
176 let backend = self.terminal.backend_mut();
177 if let Some(cursor) = cursor {
178 queue!(backend, cursor, Show)?;
179 }
180 execute!(backend, EndSynchronizedUpdate)
181 }
182
183 fn report_releases(&mut self, releases: bool) {
185 if self.releases == releases {
186 return;
187 }
188 self.releases = releases;
189 let backend = self.terminal.backend_mut();
190 let asked = if releases {
191 execute!(backend, PushKeyboardEnhancementFlags(RELEASES))
192 } else {
193 execute!(backend, PopKeyboardEnhancementFlags)
194 };
195 if let Err(failure) = asked {
196 error!("could not ask the terminal about key releases: {failure}");
197 }
198 }
199}
200
201fn take_screen<B>(terminal: &mut Terminal<B>, releases: bool) -> io::Result<()>
207where
208 B: ratatui::backend::Backend<Error = io::Error> + Write,
209{
210 let backend = terminal.backend_mut();
211 execute!(
212 backend,
213 EnterAlternateScreen,
214 EnableBracketedPaste,
215 EnableFocusChange,
216 Clear(ClearType::All)
217 )?;
218 execute!(backend, PushKeyboardEnhancementFlags(KEYS)).ok();
220 if releases {
221 execute!(backend, PushKeyboardEnhancementFlags(RELEASES)).ok();
222 }
223 terminal.hide_cursor()?;
224 terminal.swap_buffers();
227 terminal.swap_buffers();
228 Ok(())
229}
230
231#[derive(Resource)]
238pub struct TuiTestScreen {
239 terminal: Terminal<TestBackend>,
240 written: Vec<u8>,
241 repainted: Vec<Rect>,
242}
243
244impl TuiTestScreen {
245 pub fn new(width: u16, height: u16) -> Self {
247 let Ok(terminal) = Terminal::new(TestBackend::new(width, height));
248 Self {
249 terminal,
250 written: Vec::new(),
251 repainted: Vec::new(),
252 }
253 }
254
255 pub fn resize(&mut self, width: u16, height: u16) {
258 self.terminal.backend_mut().resize(width, height);
259 }
260
261 pub fn size(&self) -> Size {
263 let area = self.buffer().area;
264 Size::new(area.width, area.height)
265 }
266
267 pub fn buffer(&self) -> &Buffer {
269 self.terminal.backend().buffer()
270 }
271
272 pub fn rows(&self) -> Vec<String> {
274 let buffer = self.buffer();
275 let area = buffer.area;
276 (area.top()..area.bottom())
277 .map(|y| {
278 let cells = (area.left()..area.right()).filter_map(|x| buffer.cell((x, y)));
279 cells.map(|cell| cell.symbol()).collect()
280 })
281 .collect()
282 }
283
284 pub fn written(&self) -> &[u8] {
286 &self.written
287 }
288
289 pub fn repainted(&self) -> &[Rect] {
292 &self.repainted
293 }
294
295 pub(crate) fn draw(&mut self, draw: impl FnOnce(&mut Frame), repaint: &[Rect]) {
297 let Ok(completed) = self.terminal.draw(draw);
298 let screen = completed.area;
299 let areas = repaint.iter().map(|area| area.intersection(screen));
300 self.repainted.extend(areas.filter(|area| !area.is_empty()));
301 }
302}
303
304pub(crate) fn has_screen(tui: Option<Res<Tui>>, test: Option<Res<TuiTestScreen>>) -> bool {
306 tui.is_some() || test.is_some()
307}
308
309impl Drop for Tui {
310 fn drop(&mut self) {
311 self.leave(self.keep_screen).ok();
312 }
313}
314
315pub(crate) fn open_terminal(
318 scratch: Option<Res<Scratch>>,
319 wake: Res<Wake>,
320 mut screen: ResMut<TerminalSize>,
321 mut commands: Commands,
322) {
323 commands.insert_resource(Theme::of_terminal());
325 super::highlight::warm_up();
326 let log = scratch.map(|scratch| scratch.log().unwrap_or_else(|| PathBuf::from(NOWHERE)));
328 let opened =
329 Tui::open(log.as_deref()).and_then(|tui| Ok((tui, TerminalInput::start(wake.clone())?)));
330 match opened {
331 Ok((tui, input)) => {
332 if let Ok(size) = tui.terminal.size() {
333 screen.0 = Rect::new(0, 0, size.width, size.height);
334 }
335 commands.insert_resource(tui);
336 commands.insert_resource(input);
337 }
338 Err(failure) => {
339 error!("could not open the terminal: {failure}");
340 commands.trigger(Exit(ExitReason::Failed(1)));
341 }
342 }
343}
344
345pub(crate) fn keep_screen_on_reload(reason: Option<Res<ExitReason>>, tui: Option<ResMut<Tui>>) {
348 if let (Some(mut tui), Some(reason)) = (tui, reason)
349 && matches!(*reason, ExitReason::Reload | ExitReason::Switch(_))
350 {
351 tui.keep_screen = true;
352 }
353}
354
355pub(crate) fn write_to_terminal(
359 (tui, test): (Option<ResMut<Tui>>, Option<ResMut<TuiTestScreen>>),
360 mut writes: MessageReader<TerminalWrite>,
361 mut copies: MessageReader<CopyToClipboard>,
362) {
363 let bytes: Vec<u8> = writes
364 .read()
365 .flat_map(|write| write.0.iter().copied())
366 .chain(copies.read().flat_map(CopyToClipboard::escape))
367 .collect();
368 if bytes.is_empty() {
369 return;
370 }
371 let mut tui = match (tui, test) {
372 (Some(tui), _) => tui,
373 (None, Some(mut test)) => return test.written.extend(bytes),
374 (None, None) => return,
375 };
376 let backend = tui.terminal.backend_mut();
377 if let Err(failure) = backend.write_all(&bytes).and_then(|()| backend.flush()) {
378 error!("could not write to the terminal: {failure}");
379 }
380}
381
382pub(crate) fn report_releases(mut tui: ResMut<Tui>, bindings: Query<&KeyBinding>) {
384 let releases = bindings.iter().any(|binding| binding.releases);
385 tui.report_releases(releases);
386}
387
388pub(crate) fn run_in_terminal(world: &mut World) {
393 let mut runs = world.query_filtered::<Entity, (With<RunInTerminal>, Without<TerminalExit>)>();
394 let runs: Vec<Entity> = runs.iter(world).collect();
395 let input = world.remove_resource::<TerminalInput>();
396 let reading = input.is_some();
397 if let Some(input) = input {
398 input.stop();
399 }
400 let mut screen = None;
401 if let Some(mut tui) = world.get_resource_mut::<Tui>() {
402 match tui.leave(false) {
403 Ok(()) => screen = tui.screen.try_clone().ok(),
404 Err(failure) => error!("could not hand the terminal over: {failure}"),
405 }
406 }
407 for entity in runs {
408 let Some(mut run) = world.get_mut::<RunInTerminal>(entity) else {
409 continue;
410 };
411 let exit = run_with(&mut run.0, screen.as_ref());
412 world.entity_mut(entity).insert(TerminalExit(exit));
413 }
414 let size = world.get_resource_mut::<Tui>().and_then(|mut tui| {
415 if let Err(failure) = tui.enter() {
416 error!("could not take the terminal back: {failure}");
417 }
418 tui.terminal.size().ok()
419 });
420 if let Some(size) = size
422 && let Some(mut screen) = world.get_resource_mut::<TerminalSize>()
423 {
424 screen.set_if_neq(TerminalSize(Rect::new(0, 0, size.width, size.height)));
425 }
426 let wake = world.get_resource::<Wake>().cloned();
427 if reading && let Some(wake) = &wake {
428 match TerminalInput::start(wake.clone()) {
429 Ok(input) => world.insert_resource(input),
430 Err(failure) => error!("could not read the terminal again: {failure}"),
431 }
432 }
433 world.write_message(RequestRedraw);
434 if let Some(wake) = wake {
436 wake.wake();
437 }
438}
439
440fn run_with(command: &mut Command, screen: Option<&File>) -> Result<ExitStatus, String> {
443 for secret in secret_variables() {
444 command.env_remove(secret);
445 }
446 if let Some(screen) = screen {
447 let output = screen.try_clone().map_err(|failure| failure.to_string())?;
448 let errors = screen.try_clone().map_err(|failure| failure.to_string())?;
449 command
450 .stdout(Stdio::from(output))
451 .stderr(Stdio::from(errors));
452 }
453 let program = command.get_program().to_string_lossy().into_owned();
454 command
455 .status()
456 .map_err(|failure| format!("could not run {program}: {failure}"))
457}
458
459#[cfg(unix)]
460fn screen() -> io::Result<File> {
461 use std::os::fd::AsFd;
462 Ok(File::from(io::stdout().as_fd().try_clone_to_owned()?))
463}
464
465#[cfg(windows)]
466fn screen() -> io::Result<File> {
467 use std::os::windows::io::AsHandle;
468 Ok(File::from(io::stdout().as_handle().try_clone_to_owned()?))
469}
470
471#[cfg(unix)]
472fn redirect_output(log: &Path) -> io::Result<()> {
473 use std::os::fd::AsRawFd;
474 if let Some(directory) = log.parent() {
475 std::fs::create_dir_all(directory)?;
476 }
477 let file = std::fs::OpenOptions::new()
478 .create(true)
479 .append(true)
480 .open(log)?;
481 for target in [libc::STDOUT_FILENO, libc::STDERR_FILENO] {
482 if unsafe { libc::dup2(file.as_raw_fd(), target) } < 0 {
485 return Err(io::Error::last_os_error());
486 }
487 }
488 Ok(())
489}
490
491#[cfg(not(unix))]
494fn redirect_output(_log: &Path) -> io::Result<()> {
495 Ok(())
496}
497
498#[cfg(test)]
499mod tests;