skim 4.0.0

Fuzzy Finder in rust!
Documentation
use std::io::BufWriter;
use std::ops::{Deref, DerefMut};
use std::sync::Once;

use color_eyre::eyre::Result;
use crossterm::event::KeyEventKind;
use crossterm::event::{DisableBracketedPaste, EnableBracketedPaste};
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::{self, cursor};
use futures::{FutureExt as _, StreamExt as _};
use ratatui::layout::Rect;
use ratatui::prelude::{Backend, CrosstermBackend};
use ratatui::{TerminalOptions, Viewport};
use tokio::sync::mpsc::{Receiver, Sender, channel};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;

use super::util::cursor_pos_from_tty;
use super::{Event, Size};

const TICK_RATE: f64 = 12.;
static PANIC_HOOK_SET: Once = Once::new();

/// Terminal user interface handler for skim
pub struct Tui<B: Backend = ratatui::backend::CrosstermBackend<BufWriter<std::io::Stderr>>>
where
    B::Error: Send + Sync + 'static,
{
    /// The ratatui terminal instance
    pub terminal: ratatui::Terminal<B>,
    /// Background task handle for event polling
    pub task: Option<JoinHandle<()>>,
    /// Receiver for TUI events
    pub event_rx: Receiver<Event>,
    /// Sender for TUI events
    pub event_tx: Sender<Event>,
    /// Tick rate for updates (ticks per second)
    pub tick_rate: f64,
    /// Token for cancelling background tasks
    pub cancellation_token: CancellationToken,
    /// Whether running in fullscreen mode
    pub is_fullscreen: bool,
}

impl Tui {
    /// Creates a TUI with the default backend (buffered stderr) and the specified height
    pub fn new_with_height(height: Size) -> Result<Self> {
        let backend = CrosstermBackend::new(std::io::BufWriter::new(std::io::stderr()));
        Self::new_with_height_and_backend(backend, height)
    }
}

impl<B: Backend> Tui<B>
where
    B::Error: Send + Sync + 'static,
{
    /// Creates a new TUI with the specified backend and height
    pub fn new_with_height_and_backend(backend: B, height: Size) -> Result<Self> {
        let event_channel = channel(1024 * 1024);

        let term_height = backend.size().expect("Failed to get terminal height").height;
        let lines = match height {
            Size::Percent(100) => None,
            Size::Fixed(lines) => Some(lines),
            Size::Percent(p) => Some(term_height * p / 100),
        };

        let viewport = if let Some(mut height) = lines {
            // Until https://github.com/crossterm-rs/crossterm/issues/919 is fixed, we need to do it ourselves
            let cursor_pos = cursor_pos_from_tty()?;
            let mut y = cursor_pos.1 - 1;
            height = height.min(term_height);
            if term_height - cursor_pos.1 < height {
                let to_scroll = height - (term_height - cursor_pos.1) - 1;
                crossterm::execute!(std::io::stderr(), crossterm::terminal::ScrollUp(to_scroll))?;
                y = y.saturating_sub(to_scroll);
            }
            Viewport::Fixed(Rect::new(
                0,
                y,
                backend.size().expect("Failed to get terminal width").width - 1,
                height,
            ))
        } else {
            Viewport::Fullscreen
        };

        set_panic_hook();
        Ok(Self {
            terminal: ratatui::Terminal::with_options(backend, TerminalOptions { viewport })?,
            task: None,
            event_rx: event_channel.1,
            event_tx: event_channel.0,
            tick_rate: TICK_RATE,
            cancellation_token: CancellationToken::default(),
            is_fullscreen: lines.is_none(),
        })
    }

    /// Creates a new TUI for testing with a fullscreen viewport.
    ///
    /// This constructor skips terminal-specific operations (cursor detection,
    /// raw mode, scrolling) that don't work with `TestBackend`. Use this when
    /// writing snapshot tests or other tests that need to render the UI.
    #[cfg(any(test, feature = "test-utils"))]
    pub fn new_for_test(backend: B) -> Result<Self> {
        let event_channel = channel(1024 * 1024);
        Ok(Self {
            terminal: ratatui::Terminal::new(backend)?,
            task: None,
            event_rx: event_channel.1,
            event_tx: event_channel.0,
            tick_rate: TICK_RATE,
            cancellation_token: CancellationToken::default(),
            is_fullscreen: true,
        })
    }

    /// Enters the TUI by enabling raw mode and starting event handling
    pub fn enter(&mut self) -> Result<()> {
        crossterm::terminal::enable_raw_mode()?;
        crossterm::execute!(std::io::stderr(), EnableMouseCapture, EnableBracketedPaste)?;
        if self.is_fullscreen {
            crossterm::execute!(std::io::stderr(), EnterAlternateScreen, cursor::Hide)?;
        }
        self.start();
        Ok(())
    }

    /// Exits the TUI by stopping event handling and disabling raw mode
    pub fn exit(&mut self) -> Result<()> {
        self.stop();
        if crossterm::terminal::is_raw_mode_enabled()? {
            crossterm::execute!(
                std::io::stderr(),
                DisableMouseCapture,
                DisableBracketedPaste,
                LeaveAlternateScreen,
                cursor::Show
            )?;
            crossterm::terminal::disable_raw_mode()?;
        }
        // When using the inline layout, we want to remove all previous output
        //  -> reset cursor at the top of the drawing area
        if !self.is_fullscreen {
            self.clear()?;
            let area = self.get_frame().area();
            let orig = ratatui::layout::Position { x: area.x, y: area.y };
            self.set_cursor_position(orig)?;
        };
        Ok(())
    }
    /// Stops the TUI event loop
    /// Equivalent to self.cancel()
    pub fn stop(&self) {
        self.cancel();
    }
    /// Cancels all background tasks
    pub fn cancel(&self) {
        self.cancellation_token.cancel();
    }
    /// Starts the event loop for handling keyboard and timer events
    pub fn start(&mut self) {
        let tick_delay = std::time::Duration::from_secs_f64(1.0 / self.tick_rate);
        let event_tx_clone = self.event_tx.clone();
        let cancellation_token_clone = self.cancellation_token.clone();
        if self.task.is_some() {
            self.cancel();
        }
        self.task = Some(tokio::spawn(async move {
            let mut reader = crossterm::event::EventStream::new();
            let mut tick_interval = tokio::time::interval(tick_delay);
            loop {
                let tick_delay = tick_interval.tick();
                let crossterm_event = reader.next().fuse();
                tokio::select! {
                    _ = cancellation_token_clone.cancelled() => {
                        break;
                    }
                    maybe_event = crossterm_event => {
                      match maybe_event {
                        Some(Ok(crossterm::event::Event::Key(key))) => {
                          if key.kind == KeyEventKind::Press {
                            _ = event_tx_clone.try_send(Event::Key(key));
                          }
                        }
                        Some(Ok(crossterm::event::Event::Paste(text))) => {
                          _ = event_tx_clone.try_send(Event::Paste(text));
                        }
                        Some(Ok(crossterm::event::Event::Mouse(mouse))) => {
                          _ = event_tx_clone.try_send(Event::Mouse(mouse));
                        }
                        Some(Ok(crossterm::event::Event::Resize(cols, rows))) => {
                          _ = event_tx_clone.try_send(Event::Resize(cols, rows));
                          _ = event_tx_clone.try_send(Event::Render);
                        }
                        Some(Err(e)) => {
                          _ = event_tx_clone.try_send(Event::Error(e.to_string()));
                        }
                        None | Some(Ok(_)) => {},
                      }
                    },
                    _ = tick_delay => {
                        _ = event_tx_clone.try_send(Event::Heartbeat);
                    },
                }
            }
        }));
    }

    /// Gets the next event from the event queue
    pub async fn next(&mut self) -> Option<Event> {
        self.event_rx.recv().await
    }
}

impl<B: Backend> Deref for Tui<B>
where
    B::Error: Send + Sync + 'static,
{
    type Target = ratatui::Terminal<B>;

    fn deref(&self) -> &Self::Target {
        &self.terminal
    }
}

impl<B: Backend> DerefMut for Tui<B>
where
    B::Error: Send + Sync + 'static,
{
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.terminal
    }
}

impl<B: Backend> Drop for Tui<B>
where
    B::Error: Send + Sync + 'static,
{
    fn drop(&mut self) {
        if let Some(t) = self.task.take() {
            t.abort();
        }
        let _ = self.exit();
    }
}

fn set_panic_hook() {
    PANIC_HOOK_SET.call_once(|| {
        let hook = std::panic::take_hook();
        std::panic::set_hook(Box::new(move |panic_info| {
            ratatui::restore(); // ignore any errors as we are already failing
            hook(panic_info);
        }));
    });
}