tastty 0.1.0

Embeddable pseudoterminal sessions for Rust applications
//! [`SessionOptions`] and [`KeyAction`].

use std::fmt;
use std::sync::Arc;

use portable_pty::PtySize;
use tastty_core::{
    CellPixelSize, ClipboardTarget, HostQuery, KeyEvent, KeyScreenState, ReplyAction, TerminalSize,
};

use crate::HostProfile;
use crate::osc_policy::{ClipboardPolicy, OscPolicy};

pub(crate) const DEFAULT_SCROLLBACK: u32 = 1000;
pub(crate) const DEFAULT_ROWS: u16 = 24;
pub(crate) const DEFAULT_COLS: u16 = 80;

pub(crate) type OutputCallback = Arc<dyn Fn(&[u8]) + Send + Sync + 'static>;
pub(crate) type InputCallback = Arc<dyn Fn(&[u8]) + Send + Sync + 'static>;
pub(crate) type RedrawCallback = Arc<dyn Fn() + Send + Sync + 'static>;
pub(crate) type KeyCallback =
    Arc<dyn Fn(&KeyEvent, KeyScreenState) -> KeyAction + Send + Sync + 'static>;
pub(crate) type HostQueryCallback =
    Arc<dyn Fn(&HostQuery, &HostProfile) -> ReplyAction + Send + Sync + 'static>;

/// Action returned by the [`on_key`](SessionOptions::on_key) callback.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum KeyAction {
    /// Encode and send the key normally.
    Send,
    /// Send these bytes instead of the normal encoding.
    Replace(Vec<u8>),
    /// Discard the key silently.
    Drop,
}

/// Options used when creating a [`Terminal`](super::Terminal).
pub struct SessionOptions {
    pub(crate) scrollback: u32,
    pub(crate) rows: u16,
    pub(crate) cols: u16,
    pub(crate) pixel_cell_size: CellPixelSize,
    pub(crate) output_callback: Option<OutputCallback>,
    pub(crate) input_callback: Option<InputCallback>,
    pub(crate) redraw_callback: Option<RedrawCallback>,
    pub(crate) key_callback: Option<KeyCallback>,
    pub(crate) virtual_cols: Option<u16>,
    pub(crate) host_profile: Option<HostProfile>,
    pub(crate) host_query_callback: HostQueryCallback,
    pub(crate) echo: bool,
    pub(crate) clipboard_policy: ClipboardPolicy,
}

impl Default for SessionOptions {
    fn default() -> Self {
        Self {
            scrollback: DEFAULT_SCROLLBACK,
            rows: DEFAULT_ROWS,
            cols: DEFAULT_COLS,
            pixel_cell_size: CellPixelSize::default(),
            output_callback: None,
            input_callback: None,
            redraw_callback: None,
            key_callback: None,
            virtual_cols: None,
            host_profile: None,
            host_query_callback: default_host_query_callback(),
            echo: false,
            clipboard_policy: ClipboardPolicy::default(),
        }
    }
}

fn default_host_query_callback() -> HostQueryCallback {
    Arc::new(|_, _| ReplyAction::Send)
}

impl SessionOptions {
    /// Set the maximum number of scrollback rows retained by the parser.
    pub fn scrollback(mut self, lines: u32) -> Self {
        self.scrollback = lines;
        self
    }

    /// Set the PTY size.
    ///
    /// `size` carries the non-zero-dimensions invariant by construction
    /// (see [`tastty_core::TerminalSize::new`]). For runtime resizes
    /// driven by user signals, use [`Terminal::resize`](super::Terminal::resize)
    /// which returns [`Error::InvalidResize`](crate::Error::InvalidResize)
    /// when the requested dimensions are zero.
    pub fn size(mut self, size: TerminalSize) -> Self {
        self.rows = size.rows;
        self.cols = size.cols;
        self
    }

    /// Set the reported pixel size of one terminal cell.
    pub fn pixel_cell_size(mut self, size: CellPixelSize) -> Self {
        self.pixel_cell_size = size;
        self
    }

    /// Register a callback that receives raw PTY output bytes before
    /// they enter the parser. Called on the reader thread. The callback
    /// must not panic; a panic will terminate the reader thread silently.
    ///
    /// # Example
    ///
    /// ```no_run
    /// use tastty::SessionOptions;
    ///
    /// let opts = SessionOptions::default()
    ///     .on_output(|bytes| {
    ///         eprintln!("raw: {} bytes", bytes.len());
    ///     });
    /// ```
    pub fn on_output<F>(mut self, f: F) -> Self
    where
        F: Fn(&[u8]) + Send + Sync + 'static,
    {
        self.output_callback = Some(Arc::new(f));
        self
    }

    /// Register a callback that receives raw PTY input bytes after they are
    /// written to and flushed through the PTY writer. Called on the writer
    /// thread. The callback must not panic; a panic will terminate the writer
    /// thread.
    pub fn on_input<F>(mut self, f: F) -> Self
    where
        F: Fn(&[u8]) + Send + Sync + 'static,
    {
        self.input_callback = Some(Arc::new(f));
        self
    }

    /// Register a callback fired once per parser tick, after the reader
    /// thread has finished applying a chunk of PTY output to the parser.
    ///
    /// The callback receives no payload: it announces "screen state may
    /// have changed", giving embedders a payload-free wake source for
    /// notifier-style integrations (driver wait futures, custom render
    /// triggers) without paying for the byte-stream observation cost of
    /// [`on_output`](Self::on_output).
    ///
    /// Called on the reader thread, after `Parser::process` and after the
    /// per-tick lock on the inner parser has been released, so the
    /// callback may take its own locks on the screen without deadlocking.
    /// The callback must not panic and must not block; a slow callback
    /// stalls the reader thread and every subsequent screen update.
    pub fn on_redraw<F>(mut self, f: F) -> Self
    where
        F: Fn() + Send + Sync + 'static,
    {
        self.redraw_callback = Some(Arc::new(f));
        self
    }

    /// Register a callback that intercepts key events before encoding.
    ///
    /// The callback receives the key event and a snapshot of the screen
    /// state relevant to encoding. It returns a [`KeyAction`] that
    /// controls whether the key is sent normally, replaced, or dropped.
    ///
    /// Called on the thread that calls
    /// [`Terminal::send_key`](super::Terminal::send_key).
    /// The callback must not panic.
    ///
    /// # Example
    ///
    /// ```no_run
    /// use tastty::{KeyAction, KeyScreenState, SessionOptions};
    /// use tastty::input::KeyCode;
    ///
    /// let opts = SessionOptions::default()
    ///     .on_key(|key, _state| {
    ///         if matches!(key.code, KeyCode::Insert) {
    ///             KeyAction::Drop
    ///         } else {
    ///             KeyAction::Send
    ///         }
    ///     });
    /// ```
    pub fn on_key<F>(mut self, f: F) -> Self
    where
        F: Fn(&KeyEvent, KeyScreenState) -> KeyAction + Send + Sync + 'static,
    {
        self.key_callback = Some(Arc::new(f));
        self
    }

    /// Set a virtual column width for the parser that differs from the PTY width.
    /// The child process sees the PTY width (from size()), but the parser uses
    /// virtual_cols for its buffer, enabling horizontal scrolling without wrapping.
    pub fn virtual_cols(mut self, cols: u16) -> Self {
        self.virtual_cols = Some(cols);
        self
    }

    /// Set a custom host profile for terminal identity and default colors.
    /// Without this, tastty uses sensible VT220-compatible defaults.
    pub fn host_profile(mut self, profile: HostProfile) -> Self {
        self.host_profile = Some(profile);
        self
    }

    /// Register a callback that decides the wire reply for each host
    /// query [`ScreenEvent`](tastty_core::ScreenEvent) drained by the
    /// reader thread.
    ///
    /// The callback receives the [`HostQuery`] the parser projected from
    /// the event (DA1/DA2/DA3, XTVERSION, DSR, DECRQM, DECRQSS, XTGETTCAP,
    /// OSC color, XTWINOPS, Kitty keyboard) and a borrow of the session's
    /// [`HostProfile`]. It returns a [`ReplyAction`]:
    ///
    /// - [`ReplyAction::Send`] writes the canonical reply
    ///   ([`auto_reply_bytes`](tastty_core::host_reply::auto_reply_bytes)
    ///   against the host profile). The default callback returns this for
    ///   every query, matching the historical "auto-reply on" behavior.
    /// - [`ReplyAction::Replace`] writes the encoded bytes of the
    ///   provided [`HostReply`](crate::HostReply) instead. Typical use
    ///   cases: claim a different identity in a test fixture, swap an
    ///   XTGETTCAP answer for one the host knows.
    /// - [`ReplyAction::Drop`] writes no bytes for this query. Typical
    ///   use cases: suppress fingerprintable replies in a hardened
    ///   deployment (XTVERSION, XTGETTCAP), gate replies on policy.
    ///
    /// # Threading and contract
    ///
    /// The callback runs on the reader thread, after the parser write
    /// guard has been released and after [`ClipboardPolicy`] has been
    /// applied to drained events. It must not panic; a panic terminates
    /// the reader thread silently. It must not block; a slow callback
    /// stalls every subsequent screen update. It must not reacquire the
    /// parser write guard (deadlock with the reader thread).
    ///
    /// # Example
    ///
    /// ```no_run
    /// use tastty::host_reply::{HostQuery, HostReply, ReplyAction};
    /// use tastty::SessionOptions;
    ///
    /// let opts = SessionOptions::default().on_host_query(|q, _host| match q {
    ///     HostQuery::Xtversion => ReplyAction::Drop,
    ///     HostQuery::Da1 => ReplyAction::Replace(HostReply::Da1(b"\x1b[?42c".to_vec())),
    ///     _ => ReplyAction::Send,
    /// });
    /// ```
    ///
    /// [`HostQuery`]: tastty_core::HostQuery
    /// [`ReplyAction`]: tastty_core::ReplyAction
    /// [`ReplyAction::Send`]: tastty_core::ReplyAction::Send
    /// [`ReplyAction::Replace`]: tastty_core::ReplyAction::Replace
    /// [`ReplyAction::Drop`]: tastty_core::ReplyAction::Drop
    pub fn on_host_query<F>(mut self, f: F) -> Self
    where
        F: Fn(&HostQuery, &HostProfile) -> ReplyAction + Send + Sync + 'static,
    {
        self.host_query_callback = Arc::new(f);
        self
    }

    /// Enable or disable PTY input echo at spawn time. Default: `false`.
    ///
    /// Controls the [POSIX termios `ECHO`][termios] flag on the PTY
    /// when the child is spawned. With the default, bytes written via
    /// [`Terminal::send`](super::Terminal::send) are not reflected back
    /// unless the child itself writes them, so embedders rendering their
    /// own input feed do not see every keystroke twice.
    ///
    /// Set to `true` for children that do not manage their own
    /// termios (`cat`, `sleep`) when the embedder relies on the
    /// kernel line discipline to render typed input.
    ///
    /// On Windows this setter has no effect: `portable-pty`'s ConPTY
    /// backend exposes no host-side echo control, and the Windows
    /// analogue `ENABLE_ECHO_INPUT` is owned by the child via
    /// `SetConsoleMode`.
    ///
    /// [termios]: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/termios.h.html
    pub fn echo(mut self, enabled: bool) -> Self {
        self.echo = enabled;
        self
    }

    /// Replace the entire OSC 52 clipboard policy. Default:
    /// [`ClipboardPolicy::default`] -- read = deny, write = allow across
    /// every buffer.
    ///
    /// Per-target overrides via [`with_clipboard_read`](Self::with_clipboard_read)
    /// and [`with_clipboard_write`](Self::with_clipboard_write) are usually
    /// preferable; reach for this setter when the embedder already owns a
    /// fully-specified policy value to install.
    pub fn clipboard_policy(mut self, policy: ClipboardPolicy) -> Self {
        self.clipboard_policy = policy;
        self
    }

    /// Override the clipboard read policy for one buffer.
    ///
    /// Reads (`OSC 52 ; <Pc> ; ?`) default to [`OscPolicy::Deny`] for every
    /// buffer to prevent shell-injection exfiltration of clipboard contents.
    /// Embedders that surface a clipboard read flow (a paste-approval UI,
    /// a sandboxed integration test) opt the relevant buffers back in here.
    ///
    /// # Example
    ///
    /// ```no_run
    /// use tastty::{ClipboardTarget, OscPolicy, SessionOptions};
    ///
    /// let opts = SessionOptions::default()
    ///     .with_clipboard_read(ClipboardTarget::Primary, OscPolicy::Allow);
    /// ```
    pub fn with_clipboard_read(mut self, target: ClipboardTarget, policy: OscPolicy) -> Self {
        self.clipboard_policy.read.set(target, policy);
        self
    }

    /// Override the clipboard write policy for one buffer.
    ///
    /// Writes and clears (`OSC 52 ; <Pc> ; <base64>` and
    /// `OSC 52 ; <Pc> ;`) default to [`OscPolicy::Allow`] because most
    /// applications legitimately copy to the clipboard. Embedders running
    /// in hardened deployments can deny specific buffers here.
    pub fn with_clipboard_write(mut self, target: ClipboardTarget, policy: OscPolicy) -> Self {
        self.clipboard_policy.write.set(target, policy);
        self
    }

    pub(crate) fn pty_size(&self) -> PtySize {
        PtySize {
            rows: self.rows,
            cols: self.cols,
            pixel_width: self.cols.saturating_mul(self.pixel_cell_size.width),
            pixel_height: self.rows.saturating_mul(self.pixel_cell_size.height),
        }
    }
}

impl fmt::Debug for SessionOptions {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("SessionOptions")
            .field("scrollback", &self.scrollback)
            .field("rows", &self.rows)
            .field("cols", &self.cols)
            .field("pixel_cell_size", &self.pixel_cell_size)
            .field(
                "output_callback",
                &self.output_callback.as_ref().map(|_| "<callback>"),
            )
            .field(
                "input_callback",
                &self.input_callback.as_ref().map(|_| "<callback>"),
            )
            .field(
                "redraw_callback",
                &self.redraw_callback.as_ref().map(|_| "<callback>"),
            )
            .field(
                "key_callback",
                &self.key_callback.as_ref().map(|_| "<callback>"),
            )
            .field("host_query_callback", &"<callback>")
            .field("virtual_cols", &self.virtual_cols)
            .field("clipboard_policy", &self.clipboard_policy)
            .finish()
    }
}