tinycrossterm 0.1.0

Minimal, feature-gated, WASM-compatible subset of crossterm
Documentation
use std::io::Write;

use crate::Command;

/// Switches to the alternate screen buffer.
#[derive(Debug, Clone, Copy)]
pub struct EnterAlternateScreen;

impl Command for EnterAlternateScreen {
    fn write_ansi(&self, w: &mut impl Write) -> std::io::Result<()> {
        w.write_all(b"\x1b[?1049h")
    }
}

/// Switches back to the main screen buffer.
#[derive(Debug, Clone, Copy)]
pub struct LeaveAlternateScreen;

impl Command for LeaveAlternateScreen {
    fn write_ansi(&self, w: &mut impl Write) -> std::io::Result<()> {
        w.write_all(b"\x1b[?1049l")
    }
}

/// Clears part or all of the terminal screen.
#[derive(Debug, Clone, Copy)]
pub struct Clear(pub ClearType);

impl Command for Clear {
    fn write_ansi(&self, w: &mut impl Write) -> std::io::Result<()> {
        match self.0 {
            ClearType::All => w.write_all(b"\x1b[2J"),
            ClearType::Purge => w.write_all(b"\x1b[3J"),
            ClearType::FromCursorDown => w.write_all(b"\x1b[J"),
            ClearType::FromCursorUp => w.write_all(b"\x1b[1J"),
            ClearType::CurrentLine => w.write_all(b"\x1b[2K"),
            ClearType::UntilNewLine => w.write_all(b"\x1b[K"),
        }
    }
}

/// Specifies which part of the screen to clear.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ClearType {
    All,
    Purge,
    FromCursorDown,
    FromCursorUp,
    CurrentLine,
    UntilNewLine,
}

// ---------------------------------------------------------------------------
// Platform-specific: raw mode + terminal size
// ---------------------------------------------------------------------------

#[cfg(unix)]
mod unix {
    use std::io;
    use std::sync::Mutex;

    static ORIGINAL_TERMIOS: Mutex<Option<libc::termios>> = Mutex::new(None);

    pub fn enable_raw_mode() -> io::Result<()> {
        let mut original = ORIGINAL_TERMIOS.lock().unwrap();
        if original.is_some() {
            return Ok(()); // already in raw mode
        }

        unsafe {
            let mut termios: libc::termios = std::mem::zeroed();
            if libc::tcgetattr(libc::STDIN_FILENO, &mut termios) != 0 {
                return Err(io::Error::last_os_error());
            }
            let saved = termios;

            // Enter raw mode (mirrors cfmakeraw)
            termios.c_iflag &= !(libc::IGNBRK
                | libc::BRKINT
                | libc::PARMRK
                | libc::ISTRIP
                | libc::INLCR
                | libc::IGNCR
                | libc::ICRNL
                | libc::IXON);
            termios.c_oflag &= !libc::OPOST;
            termios.c_lflag &=
                !(libc::ECHO | libc::ECHONL | libc::ICANON | libc::ISIG | libc::IEXTEN);
            termios.c_cflag &= !(libc::CSIZE | libc::PARENB);
            termios.c_cflag |= libc::CS8;
            termios.c_cc[libc::VMIN] = 1;
            termios.c_cc[libc::VTIME] = 0;

            if libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &termios) != 0 {
                return Err(io::Error::last_os_error());
            }

            *original = Some(saved);
        }
        Ok(())
    }

    pub fn disable_raw_mode() -> io::Result<()> {
        let mut original = ORIGINAL_TERMIOS.lock().unwrap();
        if let Some(termios) = original.take() {
            unsafe {
                if libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &termios) != 0 {
                    return Err(io::Error::last_os_error());
                }
            }
        }
        Ok(())
    }

    pub fn size() -> io::Result<(u16, u16)> {
        unsafe {
            let mut ws: libc::winsize = std::mem::zeroed();
            if libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut ws) != 0 {
                return Err(io::Error::last_os_error());
            }
            Ok((ws.ws_col, ws.ws_row))
        }
    }
}

#[cfg(target_arch = "wasm32")]
mod wasm {
    use std::io;

    pub fn enable_raw_mode() -> io::Result<()> {
        // WASI terminals (e.g. libghostty) may already be in raw mode.
        // No termios available — this is a no-op.
        Ok(())
    }

    pub fn disable_raw_mode() -> io::Result<()> {
        Ok(())
    }

    pub fn size() -> io::Result<(u16, u16)> {
        #[cfg(target_os = "wasi")]
        {
            query_cursor_position()
        }
        #[cfg(not(target_os = "wasi"))]
        {
            Err(io::Error::new(
                io::ErrorKind::Unsupported,
                "terminal size detection requires WASI",
            ))
        }
    }

    /// Query terminal size via the cursor-position-report trick:
    /// save cursor, move to bottom-right, ask for position, restore cursor.
    ///
    /// Requires raw mode and a VT-compatible terminal. Should be called
    /// before event reading begins to avoid consuming pending input.
    #[cfg(target_os = "wasi")]
    fn query_cursor_position() -> io::Result<(u16, u16)> {
        use std::io::{Read, Write};
        use std::os::fd::AsRawFd;

        let mut stdout = io::stdout().lock();
        stdout.write_all(b"\x1b7\x1b[9999;9999H\x1b[6n\x1b8")?;
        stdout.flush()?;

        let fd = io::stdin().as_raw_fd();
        unsafe {
            let mut pfd = libc::pollfd {
                fd,
                events: libc::POLLIN,
                revents: 0,
            };
            if libc::poll(&mut pfd, 1, 1000) <= 0 {
                return Err(io::Error::new(
                    io::ErrorKind::TimedOut,
                    "terminal did not respond to CPR query",
                ));
            }
        }

        // Read response: ESC [ rows ; cols R
        let mut buf = [0u8; 32];
        let mut len = 0;
        let mut stdin = io::stdin().lock();
        while len < buf.len() {
            match stdin.read(&mut buf[len..len + 1]) {
                Ok(1) => {
                    len += 1;
                    if buf[len - 1] == b'R' {
                        break;
                    }
                }
                _ => break,
            }
        }

        // Parse ESC [ rows ; cols R
        let csi = buf[..len]
            .windows(2)
            .position(|w| w == b"\x1b[")
            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "no CSI in CPR response"))?;
        let inner = &buf[csi + 2..len];
        let r = inner
            .iter()
            .position(|&b| b == b'R')
            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "no R in CPR response"))?;
        let params = std::str::from_utf8(&inner[..r])
            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid CPR response"))?;
        let mut parts = params.split(';');
        let rows: u16 = parts
            .next()
            .and_then(|s| s.parse().ok())
            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "bad CPR rows"))?;
        let cols: u16 = parts
            .next()
            .and_then(|s| s.parse().ok())
            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "bad CPR cols"))?;
        Ok((cols, rows))
    }
}

/// Enables raw mode on the terminal.
pub fn enable_raw_mode() -> std::io::Result<()> {
    #[cfg(unix)]
    {
        unix::enable_raw_mode()
    }
    #[cfg(target_arch = "wasm32")]
    {
        wasm::enable_raw_mode()
    }
}

/// Disables raw mode, restoring the terminal to its previous state.
pub fn disable_raw_mode() -> std::io::Result<()> {
    #[cfg(unix)]
    {
        unix::disable_raw_mode()
    }
    #[cfg(target_arch = "wasm32")]
    {
        wasm::disable_raw_mode()
    }
}

/// Returns the terminal size as (columns, rows).
pub fn size() -> std::io::Result<(u16, u16)> {
    #[cfg(unix)]
    {
        unix::size()
    }
    #[cfg(target_arch = "wasm32")]
    {
        wasm::size()
    }
}