use anyhow::Result;
use crossterm::{
cursor::MoveTo,
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind, MouseEvent,
MouseEventKind,
},
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
ExecutableCommand,
};
use ratatui::{backend::CrosstermBackend, layout::Rect, Terminal, TerminalOptions, Viewport};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU16, Ordering};
use crate::app::App;
struct OnErrGuard<F: FnMut()> {
armed: bool,
cleanup: F,
}
impl<F: FnMut()> OnErrGuard<F> {
fn new(cleanup: F) -> Self {
Self {
armed: true,
cleanup,
}
}
fn disarm(&mut self) {
self.armed = false;
}
}
impl<F: FnMut()> Drop for OnErrGuard<F> {
fn drop(&mut self) {
if self.armed {
(self.cleanup)();
}
}
}
pub const COMPACT_HEIGHT: u16 = 8;
static IS_COMPACT_MODE: AtomicBool = AtomicBool::new(false);
static COMPACT_START_ROW: AtomicU16 = AtomicU16::new(0);
pub fn install_panic_hook() {
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
if IS_COMPACT_MODE.load(Ordering::Relaxed) {
let _ = cleanup_terminal_compact();
} else {
let _ = cleanup_terminal();
}
original_hook(panic_info);
}));
}
#[allow(dead_code)]
pub fn setup_terminal() -> Result<Terminal<CrosstermBackend<std::io::Stderr>>> {
install_panic_hook();
IS_COMPACT_MODE.store(false, Ordering::Relaxed);
enable_raw_mode()?;
std::io::stderr().execute(EnterAlternateScreen)?;
std::io::stderr().execute(EnableMouseCapture)?;
let backend = CrosstermBackend::new(std::io::stderr());
let terminal = Terminal::new(backend)?;
Ok(terminal)
}
pub fn setup_terminal_compact() -> Result<Terminal<CrosstermBackend<std::io::Stderr>>> {
install_panic_hook();
enable_raw_mode()?;
IS_COMPACT_MODE.store(true, Ordering::Relaxed);
let mut raw_guard = OnErrGuard::new(|| {
let _ = disable_raw_mode();
IS_COMPACT_MODE.store(false, Ordering::Relaxed);
});
let (_, cursor_row) = query_cursor_position();
let (term_width, term_height) = crossterm::terminal::size().unwrap_or((80, 24));
let max_height = term_height.min(COMPACT_HEIGHT);
let lines_after_cursor = COMPACT_HEIGHT.saturating_sub(1);
let available_lines = term_height.saturating_sub(cursor_row).saturating_sub(1);
let missing_lines = lines_after_cursor.saturating_sub(available_lines);
{
use std::io::Write;
for _ in 0..lines_after_cursor {
let _ = writeln!(std::io::stderr());
}
let _ = std::io::stderr().flush();
}
let start_row = cursor_row.saturating_sub(missing_lines);
COMPACT_START_ROW.store(start_row, Ordering::Relaxed);
let viewport_area = Rect {
x: 0,
y: start_row,
width: term_width,
height: max_height,
};
std::io::stderr().execute(EnableMouseCapture)?;
let backend = CrosstermBackend::new(std::io::stderr());
let terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Fixed(viewport_area),
},
)?;
raw_guard.disarm(); Ok(terminal)
}
#[cfg(unix)]
fn query_cursor_position() -> (u16, u16) {
use std::io::{Read, Write};
use std::time::Duration;
if std::io::stderr().write_all(b"\x1B[6n").is_err() || std::io::stderr().flush().is_err() {
return (0, 0);
}
let Ok(mut tty) = std::fs::OpenOptions::new().read(true).open("/dev/tty") else {
return (0, 0);
};
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
std::thread::spawn(move || {
let mut buf = Vec::with_capacity(16);
let mut byte = [0u8; 1];
loop {
match tty.read(&mut byte) {
Ok(1) => {
buf.push(byte[0]);
if byte[0] == b'R' {
let _ = tx.send(buf);
return;
}
}
_ => return,
}
}
});
let data = rx
.recv_timeout(Duration::from_millis(500))
.unwrap_or_default();
parse_cpr_response(&data).unwrap_or((0, 0))
}
fn parse_cpr_response(data: &[u8]) -> Option<(u16, u16)> {
let s = std::str::from_utf8(data).ok()?;
let after_esc = s.rsplit("\x1B[").next()?;
let inner = after_esc.strip_suffix('R')?;
let (row_s, col_s) = inner.split_once(';')?;
let row: u16 = row_s.parse().ok()?;
let col: u16 = col_s.parse().ok()?;
Some((col.saturating_sub(1), row.saturating_sub(1)))
}
#[cfg(not(unix))]
fn query_cursor_position() -> (u16, u16) {
crossterm::cursor::position().unwrap_or((0, 0))
}
pub fn cleanup_terminal_compact() -> Result<()> {
use std::io::Write;
IS_COMPACT_MODE.store(false, Ordering::Relaxed);
let _ = write!(std::io::stderr(), "\x1b[?1000l");
let _ = write!(std::io::stderr(), "\x1b[?1002l");
let _ = write!(std::io::stderr(), "\x1b[?1003l");
let _ = write!(std::io::stderr(), "\x1b[?1006l");
let _ = write!(std::io::stderr(), "\x1b[?1015l");
let _ = std::io::stderr().execute(DisableMouseCapture);
let _ = std::io::stderr().flush();
std::thread::sleep(std::time::Duration::from_millis(20));
let mut drain_count = 0;
while event::poll(std::time::Duration::from_millis(0)).unwrap_or(false) && drain_count < 100 {
let _ = event::read();
drain_count += 1;
}
let _ = disable_raw_mode();
let start_row = COMPACT_START_ROW.load(Ordering::Relaxed);
let _ = std::io::stderr().execute(MoveTo(0, start_row));
let _ = write!(std::io::stderr(), "\x1b[0J");
std::thread::sleep(std::time::Duration::from_millis(10));
let mut drain_count2 = 0;
while event::poll(std::time::Duration::from_millis(0)).unwrap_or(false) && drain_count2 < 50 {
let _ = event::read();
drain_count2 += 1;
}
let _ = write!(std::io::stderr(), "\x1b[0m\x1b[?25h");
let _ = std::io::stderr().flush();
Ok(())
}
pub fn cleanup_terminal() -> Result<()> {
use crossterm::terminal::{Clear, ClearType};
use std::io::Write;
let _ = write!(std::io::stderr(), "\x1b[?1000l"); let _ = write!(std::io::stderr(), "\x1b[?1002l"); let _ = write!(std::io::stderr(), "\x1b[?1003l"); let _ = write!(std::io::stderr(), "\x1b[?1006l"); let _ = write!(std::io::stderr(), "\x1b[?1015l"); let _ = std::io::stderr().execute(DisableMouseCapture);
let _ = std::io::stderr().flush();
std::thread::sleep(std::time::Duration::from_millis(20));
let mut drain_count = 0;
while event::poll(std::time::Duration::from_millis(0)).unwrap_or(false) && drain_count < 100 {
let _ = event::read();
drain_count += 1;
}
let _ = std::io::stderr().execute(Clear(ClearType::All));
let _ = std::io::stderr().flush();
let _ = std::io::stderr().execute(LeaveAlternateScreen);
let _ = std::io::stderr().flush();
std::thread::sleep(std::time::Duration::from_millis(10));
let mut drain_count2 = 0;
while event::poll(std::time::Duration::from_millis(0)).unwrap_or(false) && drain_count2 < 50 {
let _ = event::read();
drain_count2 += 1;
}
let _ = disable_raw_mode();
let _ = write!(std::io::stderr(), "\x1b[0m");
let _ = write!(std::io::stderr(), "\x1b[?25h");
let _ = std::io::stderr().flush();
std::thread::sleep(std::time::Duration::from_millis(10));
Ok(())
}
pub fn run_app(
terminal: &mut Terminal<CrosstermBackend<std::io::Stderr>>,
app: &mut App,
) -> Result<Option<PathBuf>> {
loop {
if app.needs_redraw() {
terminal.draw(|f| app.render(f))?;
app.clear_dirty();
}
if !event::poll(std::time::Duration::from_millis(8))? {
let _ = app.poll_search();
continue;
}
let mut scroll_up_event: Option<MouseEvent> = None;
let mut scroll_down_event: Option<MouseEvent> = None;
loop {
if event::poll(std::time::Duration::from_millis(0))? {
match event::read()? {
Event::Key(key) => {
if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
match app.handle_key(key)? {
Some(path) if !path.as_os_str().is_empty() => {
return Ok(Some(path));
}
None => {
return Ok(None);
}
_ => {}
}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => scroll_up_event = Some(mouse),
MouseEventKind::ScrollDown => scroll_down_event = Some(mouse),
_ => {
let _ = app.handle_mouse(mouse);
}
},
Event::Resize(_width, _height) => {
app.mark_dirty();
}
_ => {}
}
} else {
break;
}
}
if let Some(mouse) = scroll_up_event {
let _ = app.handle_mouse(mouse);
}
if let Some(mouse) = scroll_down_event {
let _ = app.handle_mouse(mouse);
}
}
}
#[cfg(test)]
mod tests {
use super::OnErrGuard;
#[test]
fn on_err_guard_fires_cleanup_when_dropped_armed() {
let mut calls = 0;
{
let _g = OnErrGuard::new(|| calls += 1);
}
assert_eq!(calls, 1, "cleanup should run exactly once on armed drop");
}
#[test]
fn on_err_guard_skips_cleanup_when_disarmed() {
let mut calls = 0;
{
let mut g = OnErrGuard::new(|| calls += 1);
g.disarm();
}
assert_eq!(calls, 0, "cleanup must not run after disarm");
}
#[test]
fn on_err_guard_fires_on_early_question_mark() {
fn setup_that_fails(calls: &mut i32) -> Result<(), String> {
let mut guard = OnErrGuard::new(|| *calls += 1);
Err("injected failure".to_string())?; guard.disarm();
Ok(())
}
let mut n = 0;
assert!(setup_that_fails(&mut n).is_err());
assert_eq!(n, 1, "cleanup must run when setup returns Err");
}
}