use std::io::{self, Stdout};
use std::sync::Arc;
use crossterm::{
cursor,
event::{DisableMouseCapture, EnableMouseCapture},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
#[cfg(test)]
use ratatui::backend::TestBackend;
use ratatui::prelude::Size;
use ratatui::{backend::CrosstermBackend, Terminal};
use crate::error::AppError;
type LiveTerminal = Terminal<CrosstermBackend<Stdout>>;
type PanicHook = Arc<dyn Fn(&std::panic::PanicHookInfo<'_>) + Send + Sync + 'static>;
#[cfg(test)]
type InMemoryTerminal = Terminal<TestBackend>;
enum TerminalHandle {
Stdout(LiveTerminal),
#[cfg(test)]
Test(InMemoryTerminal),
}
pub struct TuiTerminal {
terminal: TerminalHandle,
active: bool,
#[cfg(test)]
injected_restore_failure: Option<AppError>,
#[cfg(test)]
activation_count: usize,
}
pub struct PanicRestoreHookGuard {
previous: Option<PanicHook>,
}
impl PanicRestoreHookGuard {
pub fn install() -> Self {
let previous = std::panic::take_hook();
let previous: PanicHook = previous.into();
let previous_for_hook = previous.clone();
std::panic::set_hook(Box::new(move |info| {
let mut stdout = io::stdout();
let _ = restore_stdout_best_effort(&mut stdout);
previous_for_hook(info);
}));
Self {
previous: Some(previous),
}
}
}
impl Drop for PanicRestoreHookGuard {
fn drop(&mut self) {
if let Some(previous) = self.previous.take() {
std::panic::set_hook(Box::new(move |info| previous(info)));
}
}
}
fn record_err(first_err: &mut Option<AppError>, e: impl ToString) {
if first_err.is_none() {
*first_err = Some(AppError::localized(
"tui_terminal_error",
format!("终端错误: {}", e.to_string()),
format!("Terminal error: {}", e.to_string()),
));
}
}
fn restore_stdout_best_effort(stdout: &mut Stdout) -> Result<(), AppError> {
let mut first_err: Option<AppError> = None;
if let Err(e) = disable_raw_mode() {
record_err(&mut first_err, e);
}
if let Err(e) = execute!(
stdout,
cursor::Show,
LeaveAlternateScreen,
DisableMouseCapture
) {
record_err(&mut first_err, e);
}
if let Some(err) = first_err {
Err(err)
} else {
Ok(())
}
}
fn terminal_error(e: impl ToString) -> AppError {
AppError::localized(
"tui_terminal_error",
format!("终端错误: {}", e.to_string()),
format!("Terminal error: {}", e.to_string()),
)
}
impl TuiTerminal {
pub fn new() -> Result<Self, AppError> {
let mut stdout = io::stdout();
enable_raw_mode().map_err(terminal_error)?;
if let Err(e) = execute!(
stdout,
EnterAlternateScreen,
EnableMouseCapture,
cursor::Hide
) {
let _ = restore_stdout_best_effort(&mut stdout);
return Err(terminal_error(e));
}
let terminal = match Terminal::new(CrosstermBackend::new(stdout)) {
Ok(terminal) => terminal,
Err(e) => {
let mut stdout = io::stdout();
let _ = restore_stdout_best_effort(&mut stdout);
return Err(terminal_error(e));
}
};
Ok(Self {
terminal: TerminalHandle::Stdout(terminal),
active: true,
#[cfg(test)]
injected_restore_failure: None,
#[cfg(test)]
activation_count: 0,
})
}
#[cfg(test)]
pub(crate) fn new_for_test() -> Result<Self, AppError> {
const TEST_TERMINAL_WIDTH: u16 = 120;
const TEST_TERMINAL_HEIGHT: u16 = 40;
let backend = TestBackend::new(TEST_TERMINAL_WIDTH, TEST_TERMINAL_HEIGHT);
let terminal = Terminal::new(backend).expect("test terminal backend should be infallible");
Ok(Self {
terminal: TerminalHandle::Test(terminal),
active: false,
injected_restore_failure: None,
activation_count: 0,
})
}
pub fn draw<F>(&mut self, f: F) -> Result<(), AppError>
where
F: FnOnce(&mut ratatui::Frame<'_>),
{
match &mut self.terminal {
TerminalHandle::Stdout(terminal) => {
terminal.draw(f).map(|_| ()).map_err(terminal_error)
}
#[cfg(test)]
TerminalHandle::Test(terminal) => terminal.draw(f).map(|_| ()).map_err(|e| match e {}),
}
}
pub fn size(&self) -> Result<Size, AppError> {
match &self.terminal {
TerminalHandle::Stdout(terminal) => terminal.size().map_err(terminal_error),
#[cfg(test)]
TerminalHandle::Test(terminal) => terminal.size().map_err(|e| match e {}),
}
}
pub fn with_terminal_restored<T>(
&mut self,
f: impl FnOnce() -> Result<T, AppError>,
) -> Result<T, AppError> {
self.restore_best_effort()?;
struct ReactivateOnDrop<'a> {
terminal: &'a mut TuiTerminal,
reactivated: bool,
}
impl Drop for ReactivateOnDrop<'_> {
fn drop(&mut self) {
if self.reactivated {
return;
}
let _ = self.terminal.activate_best_effort();
}
}
let mut guard = ReactivateOnDrop {
terminal: self,
reactivated: false,
};
let result = f();
let activate_result = guard.terminal.activate_best_effort();
if activate_result.is_ok() {
guard.reactivated = true;
}
activate_result?;
result
}
pub fn with_terminal_restored_for_handoff<T>(
&mut self,
f: impl FnOnce() -> Result<T, AppError>,
) -> Result<T, AppError> {
if let Err(err) = self.restore_best_effort() {
self.active = false;
return recover_terminal_after_handoff_failure(self, err);
}
match f() {
Ok(value) => Ok(value),
Err(err) => recover_terminal_after_handoff_failure(self, err),
}
}
pub fn restore_best_effort(&mut self) -> Result<(), AppError> {
if !self.active {
return Ok(());
}
#[cfg(test)]
if let Some(err) = self.injected_restore_failure.take() {
return Err(err);
}
#[cfg(test)]
if matches!(self.terminal, TerminalHandle::Test(_)) {
self.active = false;
return Ok(());
}
let mut first_err: Option<AppError> = None;
if let Err(e) = disable_raw_mode() {
record_err(&mut first_err, e);
}
match &mut self.terminal {
TerminalHandle::Stdout(terminal) => {
if let Err(e) = execute!(
terminal.backend_mut(),
cursor::Show,
LeaveAlternateScreen,
DisableMouseCapture
) {
record_err(&mut first_err, e);
}
let _ = terminal.show_cursor();
}
#[cfg(test)]
TerminalHandle::Test(_) => unreachable!("test terminal should return early"),
}
if let Some(err) = first_err {
Err(err)
} else {
self.active = false;
Ok(())
}
}
pub fn activate_best_effort(&mut self) -> Result<(), AppError> {
if self.active {
return Ok(());
}
#[cfg(test)]
if let TerminalHandle::Test(terminal) = &mut self.terminal {
terminal
.clear()
.expect("test terminal backend should clear infallibly");
self.active = true;
self.activation_count += 1;
return Ok(());
}
let mut first_err: Option<AppError> = None;
if let Err(e) = enable_raw_mode() {
record_err(&mut first_err, e);
}
match &mut self.terminal {
TerminalHandle::Stdout(terminal) => {
if let Err(e) = execute!(
terminal.backend_mut(),
EnterAlternateScreen,
EnableMouseCapture,
cursor::Hide
) {
record_err(&mut first_err, e);
}
if let Err(e) = terminal.clear() {
record_err(&mut first_err, e);
}
}
#[cfg(test)]
TerminalHandle::Test(_) => unreachable!("test terminal should return early"),
}
if let Some(err) = first_err {
Err(err)
} else {
self.active = true;
Ok(())
}
}
#[cfg(test)]
pub(crate) fn inject_restore_failure_for_test(&mut self, err: AppError) {
self.injected_restore_failure = Some(err);
}
#[cfg(test)]
pub(crate) fn is_active_for_test(&self) -> bool {
self.active
}
#[cfg(test)]
pub(crate) fn activation_count_for_test(&self) -> usize {
self.activation_count
}
}
fn recover_terminal_after_handoff_failure<T>(
terminal: &mut TuiTerminal,
err: AppError,
) -> Result<T, AppError> {
match terminal.activate_best_effort() {
Ok(()) => Err(err),
Err(activate_err) => Err(AppError::localized(
"tui_terminal_restore_for_handoff_failed",
format!("恢复终端失败: {err};重新激活 TUI 失败: {activate_err}"),
format!("Failed to restore terminal: {err}; also failed to reactivate the TUI: {activate_err}"),
)),
}
}
impl Drop for TuiTerminal {
fn drop(&mut self) {
let _ = self.restore_best_effort();
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Barrier};
use std::thread;
use super::TuiTerminal;
#[test]
fn new_for_test_supports_parallel_construction_without_touching_real_tty() {
let barrier = Arc::new(Barrier::new(8));
let handles: Vec<_> = (0..8)
.map(|_| {
let barrier = barrier.clone();
thread::spawn(move || {
barrier.wait();
for _ in 0..32 {
let mut terminal = TuiTerminal::new_for_test().expect("create terminal");
let size = terminal.size().expect("read terminal size");
assert!(size.width > 0);
assert!(size.height > 0);
terminal.draw(|_| {}).expect("draw frame");
}
})
})
.collect();
for handle in handles {
handle.join().expect("thread should complete without panic");
}
}
#[test]
fn with_terminal_restored_keeps_test_terminal_usable() {
let mut terminal = TuiTerminal::new_for_test().expect("create terminal");
let value = terminal
.with_terminal_restored(|| Ok::<_, crate::error::AppError>(42))
.expect("run callback with terminal restored");
assert_eq!(value, 42);
terminal
.draw(|_| {})
.expect("draw after with_terminal_restored");
}
#[test]
fn test_terminal_can_activate_and_restore_without_real_tty() {
let mut terminal = TuiTerminal::new_for_test().expect("create test terminal");
terminal
.activate_best_effort()
.expect("test terminal should activate without a real tty");
terminal
.restore_best_effort()
.expect("test terminal should restore without a real tty");
}
#[test]
fn with_terminal_restored_for_handoff_reactivates_after_restore_failure() {
let mut terminal = TuiTerminal::new_for_test().expect("create test terminal");
terminal
.activate_best_effort()
.expect("activate test terminal first");
terminal
.draw(|frame| {
let area = frame.area();
frame.render_widget(ratatui::widgets::Paragraph::new("stale"), area);
})
.expect("draw stale frame");
terminal.inject_restore_failure_for_test(crate::error::AppError::localized(
"test.restore_failure",
"模拟 restore 失败".to_string(),
"simulated restore failure".to_string(),
));
let err = terminal
.with_terminal_restored_for_handoff(|| Ok::<_, crate::error::AppError>(()))
.expect_err("restore failure should be returned");
assert!(
err.to_string().contains("restore failure")
|| err.to_string().contains("simulated restore failure"),
"unexpected error: {err}"
);
assert!(
terminal.is_active_for_test(),
"pre-handoff restore failure should reactivate the TUI"
);
assert_eq!(
terminal.activation_count_for_test(),
2,
"handoff recovery should perform a second activation after the restore failure"
);
}
#[test]
fn with_terminal_restored_for_handoff_reactivates_after_callback_error() {
let mut terminal = TuiTerminal::new_for_test().expect("create test terminal");
terminal
.activate_best_effort()
.expect("activate test terminal first");
let err = terminal
.with_terminal_restored_for_handoff(|| {
Err::<(), _>(crate::error::AppError::localized(
"test.handoff_failure",
"模拟 handoff 失败".to_string(),
"simulated handoff failure".to_string(),
))
})
.expect_err("callback failure should be returned");
assert!(
err.to_string().contains("handoff failure")
|| err.to_string().contains("simulated handoff failure"),
"unexpected error: {err}"
);
assert!(
terminal.is_active_for_test(),
"handoff callback errors after restore should reactivate the TUI"
);
assert_eq!(
terminal.activation_count_for_test(),
2,
"handoff recovery should perform a second activation after callback failure"
);
}
}