resq-tui 0.1.6

Shared TUI components and themes for ResQ developer tools
Documentation
/*
 * Copyright 2026 ResQ
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

//! Terminal lifecycle helpers — init, restore, and event-loop runner.

use std::io;
use std::ops::{Deref, DerefMut};
use std::time::Duration;

use crossterm::{
    event::{self, Event, KeyCode, KeyModifiers},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};

/// A `ratatui` terminal backed by Crossterm.
pub type Term = Terminal<CrosstermBackend<io::Stdout>>;

/// RAII guard that owns a [`Term`] and automatically calls [`restore`] on drop.
///
/// This ensures the terminal is cleaned up even on panic or early `?` returns.
/// Use [`Deref`] / [`DerefMut`] to access the underlying [`Term`] transparently
/// (e.g. `guard.draw(|f| ...)` works).
#[must_use = "dropping the guard immediately restores the terminal — assign it to a variable"]
pub struct TerminalGuard {
    terminal: Term,
}

impl Deref for TerminalGuard {
    type Target = Term;

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

impl DerefMut for TerminalGuard {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.terminal
    }
}

impl Drop for TerminalGuard {
    fn drop(&mut self) {
        restore();
    }
}

/// Initialise raw mode and enter the alternate screen.
///
/// Returns a [`TerminalGuard`] that will call [`restore`] automatically when
/// dropped, ensuring cleanup even on panic or early `?` returns.
///
/// # Errors
/// Propagates any I/O error from Crossterm or Ratatui.
pub fn init() -> anyhow::Result<TerminalGuard> {
    enable_raw_mode()?;
    if let Err(e) = execute!(io::stdout(), EnterAlternateScreen) {
        restore();
        return Err(e.into());
    }
    match Terminal::new(CrosstermBackend::new(io::stdout())) {
        Ok(terminal) => Ok(TerminalGuard { terminal }),
        Err(e) => {
            restore();
            Err(e.into())
        }
    }
}

/// Leave the alternate screen and disable raw mode.
///
/// Safe to call even if the terminal is in a partially-initialised state.
pub fn restore() {
    let _ = execute!(io::stdout(), LeaveAlternateScreen);
    let _ = disable_raw_mode();
}

/// Implement this trait on your app state to use [`run_loop`].
pub trait TuiApp {
    /// Draw the current frame.
    fn draw(&mut self, frame: &mut ratatui::Frame);

    /// Handle a key event. Return `false` to exit the loop.
    ///
    /// # Errors
    /// Returns any application-specific error that should terminate the TUI loop.
    fn handle_key(&mut self, key: event::KeyEvent) -> anyhow::Result<bool>;
}

/// Run a standard TUI event loop with the given app.
///
/// `poll_ms` controls how frequently the loop polls for keyboard input.
/// Ctrl+C always exits. The terminal is **not** automatically initialised
/// or restored — wrap the call site with [`init`] / [`restore`].
///
/// # Errors
/// Propagates draw or event errors, and errors from the app's `handle_key`.
pub fn run_loop(terminal: &mut Term, poll_ms: u64, app: &mut dyn TuiApp) -> anyhow::Result<()> {
    let timeout = Duration::from_millis(poll_ms);
    loop {
        terminal.draw(|f| app.draw(f))?;
        if event::poll(timeout)? {
            if let Event::Key(key) = event::read()? {
                if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
                    break;
                }
                if !app.handle_key(key)? {
                    break;
                }
            }
        }
    }
    Ok(())
}