tui-test-rs 0.1.0-beta.2

In-process terminal automation, inspection, assertions, and recording
Documentation
//! Terminal emulator selection and construction.

use serde::{Deserialize, Serialize};

use crate::event::BellTracker;
use crate::profile::Profile;
use crate::terminal::alacritty::AlacrittyEmu;
use crate::terminal::emu::Emulator;

#[cfg(feature = "rio")]
use crate::terminal::rio::RioEmu;

#[cfg(feature = "ghostty")]
use crate::terminal::ghostty::GhosttyEmu;

/// The terminal emulator a session uses.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Backend {
    #[default]
    Alacritty,
    #[cfg(feature = "ghostty")]
    Ghostty,
    #[cfg(feature = "rio")]
    Rio,
}

impl Backend {
    #[cfg(all(not(feature = "ghostty"), not(feature = "rio")))]
    pub const ALL: [Self; 1] = [Self::Alacritty];
    #[cfg(all(feature = "ghostty", not(feature = "rio")))]
    pub const ALL: [Self; 2] = [Self::Alacritty, Self::Ghostty];
    #[cfg(all(not(feature = "ghostty"), feature = "rio"))]
    pub const ALL: [Self; 2] = [Self::Alacritty, Self::Rio];
    #[cfg(all(feature = "ghostty", feature = "rio"))]
    pub const ALL: [Self; 3] = [Self::Alacritty, Self::Ghostty, Self::Rio];

    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Alacritty => "alacritty",
            #[cfg(feature = "ghostty")]
            Self::Ghostty => "ghostty",
            #[cfg(feature = "rio")]
            Self::Rio => "rio",
        }
    }

    pub fn build(
        self,
        cols: u16,
        rows: u16,
        profile: &Profile,
    ) -> anyhow::Result<Box<dyn Emulator>> {
        match self {
            Self::Alacritty => Ok(Box::new(AlacrittyEmu::new(cols, rows, profile))),
            #[cfg(feature = "ghostty")]
            Self::Ghostty => Ok(Box::new(GhosttyEmu::new(cols, rows, profile)?)),
            #[cfg(feature = "rio")]
            Self::Rio => Ok(Box::new(RioEmu::new(cols, rows, profile))),
        }
    }

    /// Like [`Self::build`], but wires up bell tracking where the backend
    /// supports it. Backends without native bell support simply won't
    /// report bell events.
    pub(crate) fn build_with_bells(
        self,
        cols: u16,
        rows: u16,
        profile: &Profile,
        bells: BellTracker,
    ) -> anyhow::Result<Box<dyn Emulator>> {
        match self {
            Self::Alacritty => Ok(Box::new(AlacrittyEmu::with_bell_tracker(
                cols, rows, profile, bells,
            ))),
            #[cfg(feature = "ghostty")]
            Self::Ghostty => self.build(cols, rows, profile),
            #[cfg(feature = "rio")]
            Self::Rio => Ok(Box::new(RioEmu::with_bell_tracker(
                cols, rows, profile, bells,
            ))),
        }
    }

    const fn expected() -> &'static str {
        match (cfg!(feature = "ghostty"), cfg!(feature = "rio")) {
            (false, false) => "alacritty",
            (true, false) => "alacritty, ghostty",
            (false, true) => "alacritty, rio",
            (true, true) => "alacritty, ghostty, rio",
        }
    }
}

impl std::str::FromStr for Backend {
    type Err = String;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value.trim().to_ascii_lowercase().as_str() {
            "alacritty" => Ok(Self::Alacritty),
            #[cfg(feature = "ghostty")]
            "ghostty" => Ok(Self::Ghostty),
            #[cfg(feature = "rio")]
            "rio" => Ok(Self::Rio),
            other => Err(format!(
                "unknown terminal backend {other:?}; expected one of: {}",
                Self::expected()
            )),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn names_round_trip() {
        for backend in Backend::ALL {
            assert_eq!(backend.as_str().parse(), Ok(backend));
        }
    }

    #[test]
    fn alacritty_remains_the_default() {
        assert_eq!(Backend::default(), Backend::Alacritty);
    }

    #[cfg(not(feature = "rio"))]
    #[test]
    fn rio_is_rejected_when_its_feature_is_disabled() {
        assert!("rio".parse::<Backend>().is_err());
        assert!(serde_json::from_str::<Backend>("\"rio\"").is_err());
    }

    #[cfg(feature = "ghostty")]
    #[test]
    fn legacy_backend_spelling_is_rejected() {
        assert!("libghostty".parse::<Backend>().is_err());
        assert!(serde_json::from_str::<Backend>("\"libghostty\"").is_err());
    }

    #[test]
    fn every_enabled_backend_constructs() {
        for backend in Backend::ALL {
            let mut emulator = backend
                .build(10, 2, &Profile::default())
                .unwrap_or_else(|error| panic!("{}: {error:#}", backend.as_str()));
            emulator.process(b"ok");
            assert_eq!(emulator.viewable_rows()[0][0].ch, "o");
        }
    }
}