diff --git a/bindings/js/native/lib.rs b/bindings/js/native/lib.rs index 3693b045..c06e53d8 100644 --- a/bindings/js/native/lib.rs +++ b/bindings/js/native/lib.rs @@ -483,6 +483,7 @@ fn open_options(value: Option) -> std::result::Result std::result::Result for Backend { + fn from(backend: BackendArg) -> Self { + match backend { + BackendArg::Alacritty => Backend::Alacritty, + BackendArg::Ghostty => Backend::Ghostty, + } + } +} #[derive(Clone, Copy, clap::ValueEnum)] #[clap(rename_all = "lowercase")] @@ -116,6 +132,9 @@ pub enum Command { /// Shell to launch (defaults to the platform shell). #[arg(long, value_enum)] shell: Option, + /// Terminal emulator to use (defaults to alacritty). + #[arg(long, value_enum)] + backend: Option, /// Terminal width in columns. #[arg(long, default_value_t = DEFAULT_COLS)] cols: u16, @@ -147,6 +166,9 @@ pub enum Command { /// Arguments passed to the program. #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, + /// Terminal emulator to use (defaults to alacritty). + #[arg(long, value_enum)] + backend: Option, /// Terminal width in columns. #[arg(long, default_value_t = DEFAULT_COLS)] cols: u16, @@ -400,6 +422,21 @@ mod tests { } } + #[test] + fn open_backend_values_map_to_terminal_backends() { + let cli = Cli::try_parse_from(["tui-test", "open", "--backend", "ghostty"]) + .expect("parse backend"); + let Some(Command::Open { + backend: Some(backend), + .. + }) = cli.command + else { + panic!("expected Open with a backend"); + }; + assert_eq!(Backend::from(backend), Backend::Ghostty); + assert!(Cli::try_parse_from(["tui-test", "open", "--backend", "libghostty"]).is_err()); + } + #[test] fn run_accepts_readiness_flags() { let cli = diff --git a/crates/tui-test-cli/src/main.rs b/crates/tui-test-cli/src/main.rs index b3c98134..13230995 100644 --- a/crates/tui-test-cli/src/main.rs +++ b/crates/tui-test-cli/src/main.rs @@ -136,6 +136,7 @@ fn build_request(command: Command) -> anyhow::Result { let req = match command { Command::Open { shell, + backend, cols, rows, cwd, @@ -147,6 +148,7 @@ fn build_request(command: Command) -> anyhow::Result { } => Request::Open { shell: shell.map(Into::into), program: None, + backend: backend.map(Into::into).unwrap_or_default(), profile: profile.resolve()?, cols, rows, @@ -158,6 +160,7 @@ fn build_request(command: Command) -> anyhow::Result { Command::Run { program, args, + backend, cols, rows, cwd, @@ -172,6 +175,7 @@ fn build_request(command: Command) -> anyhow::Result { Request::Open { shell: None, program: Some(prog), + backend: backend.map(Into::into).unwrap_or_default(), profile: profile.resolve()?, cols, rows, diff --git a/crates/tui-test-cli/src/protocol.rs b/crates/tui-test-cli/src/protocol.rs index 4bac1daf..a002b77a 100644 --- a/crates/tui-test-cli/src/protocol.rs +++ b/crates/tui-test-cli/src/protocol.rs @@ -2,7 +2,8 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use tui_test::{ - Engine, OpenOptions, Operation, OperationResult, RunOptions, ScreenshotResult, TuiTestError, + Backend, Engine, OpenOptions, Operation, OperationResult, RunOptions, ScreenshotResult, + TuiTestError, }; pub use tui_test::{ErrorKind, MouseAction, Timeouts}; @@ -14,6 +15,8 @@ pub enum Request { Open { shell: Option, program: Option>, + #[serde(default)] + backend: Backend, /// Terminal settings, already resolved from the config file by the /// client. The daemon never reads that file: it is long-lived and /// shared, so it has no single working directory to resolve a @@ -154,6 +157,7 @@ impl Request { Request::Open { shell, program, + backend, profile, cols, rows, @@ -168,6 +172,7 @@ impl Request { .next() .ok_or_else(|| TuiTestError::usage("empty program"))?; Ok(Operation::Run(RunOptions { + backend, profile, program: executable, args: parts.collect(), @@ -180,6 +185,7 @@ impl Request { })) } else { Ok(Operation::Open(OpenOptions { + backend, profile, shell, cols, @@ -393,6 +399,7 @@ mod tests { Request::Open { shell: None, program: None, + backend: Backend::default(), profile: Default::default(), cols: 80, rows: 30, @@ -411,11 +418,13 @@ mod tests { match request { Request::Open { wait_ready, + backend, cols, timeouts, .. } => { assert_eq!(wait_ready, None); + assert_eq!(backend, Backend::Alacritty); assert_eq!(cols, 80); assert_eq!(timeouts, Timeouts::default()); } @@ -423,6 +432,16 @@ mod tests { } } + #[test] + fn open_round_trips_a_requested_backend() { + let raw = r#"{"kind":"open","shell":null,"program":null,"backend":"ghostty", + "cols":80,"rows":30,"cwd":null,"env":[]}"#; + match serde_json::from_str::(raw).expect("deserialize open") { + Request::Open { backend, .. } => assert_eq!(backend, Backend::Ghostty), + other => panic!("expected Open, got {other:?}"), + } + } + #[test] fn waits_accept_a_concrete_timeout_from_older_clients() { let raw = r#"{"kind":"wait_idle","timeout_ms":1234}"#; diff --git a/crates/tui-test-cli/tests/session_lifecycle.rs b/crates/tui-test-cli/tests/session_lifecycle.rs index cc73e1f9..ed35e9a2 100644 --- a/crates/tui-test-cli/tests/session_lifecycle.rs +++ b/crates/tui-test-cli/tests/session_lifecycle.rs @@ -615,6 +615,46 @@ fn sleeper() -> Vec<&'static str> { } } +fn blinking_program() -> Vec<&'static str> { + if cfg!(windows) { + vec![ + "pwsh", + "-NoLogo", + "-NoProfile", + "-Command", + "[Console]::Write(\"`e[5mX`e[0m\"); Start-Sleep -Seconds 30", + ] + } else { + vec!["sh", "-c", "printf '\\033[5mX\\033[0m'; sleep 30"] + } +} + +#[test] +fn ghostty_backend_is_used_end_to_end() { + let sandbox = Sandbox::new("ghostty-backend"); + let mut args = vec![ + "run", + "--backend", + "ghostty", + "--cols", + "10", + "--rows", + "2", + "--", + ]; + args.extend(blinking_program()); + sandbox.ok(&args); + sandbox.ok(&["wait", "text", "X"]); + + let raw = sandbox.ok(&["--json", "cells", "0", "0"]); + let payload: serde_json::Value = serde_json::from_str(&raw).expect("cells json"); + assert_eq!( + payload["data"]["cells"][0]["blink"], + serde_json::Value::Bool(true), + "Ghostty preserves SGR blink: {payload}" + ); +} + fn interactive_reader() -> &'static str { if cfg!(windows) { "Write-Output ('reader-'+'ready'); $null = Read-Host" diff --git a/crates/tui-test/src/api.rs b/crates/tui-test/src/api.rs index ba295888..142a1c1c 100644 --- a/crates/tui-test/src/api.rs +++ b/crates/tui-test/src/api.rs @@ -29,6 +29,7 @@ impl Timeouts { #[derive(Debug, Clone)] pub struct OpenOptions { + pub backend: crate::terminal::backend::Backend, pub shell: Option, /// Terminal settings, already resolved from the config file by the /// client. The daemon never reads that file: it is long-lived and shared, @@ -46,6 +47,7 @@ pub struct OpenOptions { impl Default for OpenOptions { fn default() -> Self { Self { + backend: crate::terminal::backend::Backend::default(), shell: None, profile: crate::profile::Profile::default(), cols: crate::config::DEFAULT_COLS, @@ -60,6 +62,7 @@ impl Default for OpenOptions { #[derive(Debug, Clone)] pub struct RunOptions { + pub backend: crate::terminal::backend::Backend, pub program: String, pub args: Vec, /// Terminal settings, already resolved from the config file by the diff --git a/crates/tui-test/src/engine.rs b/crates/tui-test/src/engine.rs index e327359e..694a91d1 100644 --- a/crates/tui-test/src/engine.rs +++ b/crates/tui-test/src/engine.rs @@ -53,7 +53,8 @@ pub struct LiveFrame { fn operation_summary(operation: &Operation) -> String { match operation { Operation::Open(options) => format!( - "Open {{ shell: {:?}, scrollback: {}, {}x{}, cwd: {:?}, wait_ready: {:?}, timeouts: {:?}, env: <{} vars> }}", + "Open {{ backend: {}, shell: {:?}, scrollback: {}, {}x{}, cwd: {:?}, wait_ready: {:?}, timeouts: {:?}, env: <{} vars> }}", + options.backend.as_str(), options.shell, options.profile.scrollback, options.cols, @@ -64,7 +65,8 @@ fn operation_summary(operation: &Operation) -> String { options.env.len() ), Operation::Run(options) => format!( - "Run {{ program: {:?}, args: {:?}, scrollback: {}, {}x{}, cwd: {:?}, wait_ready: {:?}, timeouts: {:?}, env: <{} vars> }}", + "Run {{ backend: {}, program: {:?}, args: {:?}, scrollback: {}, {}x{}, cwd: {:?}, wait_ready: {:?}, timeouts: {:?}, env: <{} vars> }}", + options.backend.as_str(), options.program, options.args, options.profile.scrollback, @@ -138,6 +140,7 @@ impl Engine { self.spawn( options.shell, None, + options.backend, options.profile, options.cols, options.rows, @@ -155,6 +158,7 @@ impl Engine { self.spawn( None, Some(program), + options.backend, options.profile, options.cols, options.rows, @@ -170,6 +174,7 @@ impl Engine { &self, shell: Option, program: Option>, + backend: crate::terminal::backend::Backend, profile: crate::profile::Profile, cols: u16, rows: u16, @@ -192,6 +197,7 @@ impl Engine { let session = TerminalSession::open( shell, program.clone(), + backend, profile, cols, rows, diff --git a/crates/tui-test/src/lib.rs b/crates/tui-test/src/lib.rs index 078f6a38..bdfca24b 100644 --- a/crates/tui-test/src/lib.rs +++ b/crates/tui-test/src/lib.rs @@ -16,3 +16,4 @@ mod session; pub use api::*; pub use engine::Engine; pub use runtime::{global_registry, Session, SessionHandle, SessionRegistry}; +pub use terminal::backend::Backend; diff --git a/crates/tui-test/src/session.rs b/crates/tui-test/src/session.rs index 3565f603..d64ada2e 100644 --- a/crates/tui-test/src/session.rs +++ b/crates/tui-test/src/session.rs @@ -10,7 +10,7 @@ use std::time::Instant; use crate::logger::Logger; use crate::profile::Profile; use crate::shell::{self, Shell}; -use crate::terminal::alacritty::AlacrittyEmu; +use crate::terminal::backend::Backend; use crate::terminal::emu::Emulator; use crate::terminal::integration::CommandTracker; use crate::terminal::pty::{Pty, SpawnOptions}; @@ -52,6 +52,7 @@ impl Session { pub fn open( shell: Option, program: Option>, + backend: Backend, profile: Profile, cols: u16, rows: u16, @@ -61,6 +62,8 @@ impl Session { logger: Arc, recording_path: PathBuf, ) -> anyhow::Result { + let emu = backend.build(cols, rows, &profile)?; + let (pty, reader) = if let Some(program) = &program { let (target, args) = program .split_first() @@ -80,7 +83,7 @@ impl Session { }; let state = Arc::new(Mutex::new(TermState { - emu: Box::new(AlacrittyEmu::new(cols, rows, &profile)), + emu, tracker: CommandTracker::new(), last_change: Instant::now(), awaiting_start: None, @@ -145,8 +148,12 @@ impl Session { }); logger.event(&format!( - "session open shell={:?} program={:?} {}x{}", - shell, program, cols, rows + "session open shell={:?} program={:?} backend={} {}x{}", + shell, + program, + backend.as_str(), + cols, + rows )); Ok(Session { diff --git a/crates/tui-test/src/terminal/backend.rs b/crates/tui-test/src/terminal/backend.rs new file mode 100644 index 00000000..133dd7ea --- /dev/null +++ b/crates/tui-test/src/terminal/backend.rs @@ -0,0 +1,107 @@ +//! Terminal emulator selection and construction. + +use serde::{Deserialize, Serialize}; + +use crate::profile::Profile; +use crate::terminal::alacritty::AlacrittyEmu; +use crate::terminal::emu::Emulator; + +#[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, +} + +impl Backend { + #[cfg(not(feature = "ghostty"))] + pub const ALL: [Self; 1] = [Self::Alacritty]; + #[cfg(feature = "ghostty")] + pub const ALL: [Self; 2] = [Self::Alacritty, Self::Ghostty]; + + pub const fn as_str(self) -> &'static str { + match self { + Self::Alacritty => "alacritty", + #[cfg(feature = "ghostty")] + Self::Ghostty => "ghostty", + } + } + + pub fn build( + self, + cols: u16, + rows: u16, + profile: &Profile, + ) -> anyhow::Result> { + 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)?)), + } + } + + const fn expected() -> &'static str { + if cfg!(feature = "ghostty") { + "alacritty, ghostty" + } else { + "alacritty" + } + } +} + +impl std::str::FromStr for Backend { + type Err = String; + + fn from_str(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "alacritty" => Ok(Self::Alacritty), + #[cfg(feature = "ghostty")] + "ghostty" => Ok(Self::Ghostty), + 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(feature = "ghostty")] + #[test] + fn legacy_backend_spelling_is_rejected() { + assert!("libghostty".parse::().is_err()); + assert!(serde_json::from_str::("\"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"); + } + } +} diff --git a/crates/tui-test/src/terminal/mod.rs b/crates/tui-test/src/terminal/mod.rs index 9a8fdbc0..211e0e13 100644 --- a/crates/tui-test/src/terminal/mod.rs +++ b/crates/tui-test/src/terminal/mod.rs @@ -1,4 +1,5 @@ pub mod alacritty; +pub mod backend; pub mod cell; #[cfg(test)] pub mod conformance;