use anyhow::Result; use async_trait::async_trait; use zellij_utils::pane_size::Size; #[cfg(not(windows))] use crate::os_input_output_unix::{ disable_mouse_support, enable_mouse_support, setup_ipc, AsyncSignalListener, BlockingSignalIterator, }; #[cfg(windows)] use crate::os_input_output_windows::{ disable_mouse_support, enable_mouse_support, restore_console_mode, setup_ipc, AsyncSignalListener, BlockingSignalIterator, }; use std::io::prelude::*; use std::io::IsTerminal; use std::path::Path; use std::sync::{Arc, Mutex}; use std::{io, thread, time}; use zellij_utils::{ data::Palette, errors::ErrorContext, ipc::{ClientToServerMsg, IpcReceiverWithContext, IpcSenderWithContext, ServerToClientMsg}, shared::default_palette, }; const SIGWINCH_CB_THROTTLE_DURATION: time::Duration = time::Duration::from_millis(50); pub(crate) const ENABLE_MOUSE_SUPPORT: &str = "\u{1b}[?1000h\u{1b}[?1002h\u{1b}[?1003h\u{1b}[?1015h\u{1b}[?1006h"; pub(crate) const DISABLE_MOUSE_SUPPORT: &str = "\u{1b}[?1006l\u{1b}[?1015l\u{1b}[?1003l\u{1b}[?1002l\u{1b}[?1000l"; /// Trait for async stdin reading, allowing for testable implementations #[async_trait] pub trait AsyncStdin: Send { async fn read(&mut self) -> io::Result>; } pub struct AsyncStdinReader { stdin: tokio::io::Stdin, buffer: Vec, } impl AsyncStdinReader { pub fn new() -> Self { Self { stdin: tokio::io::stdin(), buffer: vec![0u8; 10 * 1024], } } } #[async_trait] impl AsyncStdin for AsyncStdinReader { async fn read(&mut self) -> io::Result> { use tokio::io::AsyncReadExt; let n = self.stdin.read(&mut self.buffer).await?; Ok(self.buffer[..n].to_vec()) } } pub enum SignalEvent { Resize, Quit, } /// Trait for async signal listening, allowing for testable implementations #[async_trait] pub trait AsyncSignals: Send { async fn recv(&mut self) -> Option; } pub(crate) fn get_terminal_size() -> Size { match crossterm::terminal::size() { Ok((cols, rows)) => { // fallback to default values when rows/cols == 0: https://github.com/zellij-org/zellij/issues/1551 let rows = if rows != 0 { rows as usize } else { 24 }; let cols = if cols != 0 { cols as usize } else { 80 }; Size { rows, cols } }, Err(_) => Size { rows: 24, cols: 80 }, } } #[derive(Clone)] pub struct ClientOsInputOutput { send_instructions_to_server: Arc>>>, receive_instructions_from_server: Arc>>>, reading_from_stdin: Arc>>>, session_name: Arc>>, } impl std::fmt::Debug for ClientOsInputOutput { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ClientOsInputOutput").finish() } } /// The `ClientOsApi` trait represents an abstract interface to the features of an operating system that /// Zellij client requires. pub trait ClientOsApi: Send + Sync + std::fmt::Debug { /// Returns the size of the terminal. fn get_terminal_size(&self) -> Size; /// Set the terminal to /// [raw mode](https://en.wikipedia.org/wiki/Terminal_mode). fn set_raw_mode(&mut self); /// Set the terminal to /// [cooked mode](https://en.wikipedia.org/wiki/Terminal_mode). fn unset_raw_mode(&self) -> Result<(), std::io::Error>; /// Returns the writer that allows writing to standard output. fn get_stdout_writer(&self) -> Box; /// Returns a BufReader that allows to read from STDIN line by line, also locks STDIN fn get_stdin_reader(&self) -> Box; fn stdin_is_terminal(&self) -> bool { true } fn stdout_is_terminal(&self) -> bool { true } fn update_session_name(&mut self, new_session_name: String); /// Returns the raw contents of standard input. fn read_from_stdin(&mut self) -> Result, &'static str>; /// Returns a [`Box`] pointer to this [`ClientOsApi`] struct. fn box_clone(&self) -> Box; /// Sends a message to the server. fn send_to_server(&self, msg: ClientToServerMsg); /// Receives a message on client-side IPC channel // This should be called from the client-side router thread only. fn recv_from_server(&self) -> Option<(ServerToClientMsg, ErrorContext)>; fn handle_signals( &self, sigwinch_cb: Box, quit_cb: Box, resize_receiver: Option>, ); /// Establish a connection with the server socket. fn connect_to_server(&self, path: &Path); fn spawn_server(&self, socket_path: &Path, debug: bool) -> Result<(), std::io::Error> { crate::spawn_server(socket_path, debug) } fn should_install_panic_hook(&self) -> bool { true } fn load_palette(&self) -> Palette; fn enable_mouse(&self) -> Result<()>; fn disable_mouse(&self) -> Result<()>; /// Restore console input mode to its pre-Zellij state. /// /// On Windows this clears ENABLE_MOUSE_INPUT and ENABLE_VIRTUAL_TERMINAL_INPUT /// that crossterm's disable_raw_mode() leaves behind. No-op on other platforms. fn restore_console_mode(&self) {} fn env_variable(&self, _name: &str) -> Option { None } /// Returns an async stdin reader that can be polled in tokio::select fn get_async_stdin_reader(&self) -> Box { Box::new(AsyncStdinReader::new()) } /// Returns an async signal listener that can be polled in tokio::select fn get_async_signal_listener(&self) -> io::Result> { Ok(Box::new(AsyncSignalListener::new()?)) } } impl ClientOsApi for ClientOsInputOutput { fn get_terminal_size(&self) -> Size { get_terminal_size() } fn set_raw_mode(&mut self) { crossterm::terminal::enable_raw_mode().expect("could not enable raw mode"); } fn unset_raw_mode(&self) -> Result<(), std::io::Error> { crossterm::terminal::disable_raw_mode() } fn box_clone(&self) -> Box { Box::new((*self).clone()) } fn update_session_name(&mut self, new_session_name: String) { *self.session_name.lock().unwrap() = Some(new_session_name); } fn read_from_stdin(&mut self) -> Result, &'static str> { let session_name_at_calltime = { self.session_name.lock().unwrap().clone() }; // here we wait for a lock in case another thread is holding stdin // this can happen for example when switching sessions, the old thread will only be // released once it sees input over STDIN // // when this happens, we detect in the other thread that our session is ended (by comparing // the session name at the beginning of the call and the one after we read from STDIN), and // so place what we read from STDIN inside a buffer (the "reading_from_stdin" on our state) // and release the lock // // then, another thread will see there's something in the buffer immediately as it acquires // the lock (without having to wait for STDIN itself) forward this buffer and proceed to // wait for the "real" STDIN net time it is called let mut buffered_bytes = self.reading_from_stdin.lock().unwrap(); match buffered_bytes.take() { Some(buffered_bytes) => Ok(buffered_bytes), None => { let stdin = std::io::stdin(); let mut stdin = stdin.lock(); let buffer = stdin.fill_buf().unwrap(); let length = buffer.len(); let read_bytes = Vec::from(buffer); stdin.consume(length); let session_name_after_reading_from_stdin = { self.session_name.lock().unwrap().clone() }; if session_name_at_calltime.is_some() && session_name_at_calltime != session_name_after_reading_from_stdin { *buffered_bytes = Some(read_bytes); Err("Session ended") } else { Ok(read_bytes) } }, } } fn get_stdout_writer(&self) -> Box { let stdout = ::std::io::stdout(); Box::new(stdout) } fn get_stdin_reader(&self) -> Box { let stdin = ::std::io::stdin(); Box::new(stdin.lock()) } fn stdin_is_terminal(&self) -> bool { let stdin = ::std::io::stdin(); stdin.is_terminal() } fn stdout_is_terminal(&self) -> bool { let stdout = ::std::io::stdout(); stdout.is_terminal() } fn send_to_server(&self, msg: ClientToServerMsg) { match self.send_instructions_to_server.lock().unwrap().as_mut() { Some(sender) => { let _ = sender.send_client_msg(msg); }, None => { log::warn!("Server not ready, dropping message."); }, } } fn recv_from_server(&self) -> Option<(ServerToClientMsg, ErrorContext)> { self.receive_instructions_from_server .lock() .unwrap() .as_mut() .unwrap() .recv_server_msg() } fn handle_signals( &self, sigwinch_cb: Box, quit_cb: Box, resize_receiver: Option>, ) { let mut sigwinch_cb_timestamp = time::Instant::now(); let signals = BlockingSignalIterator::new(resize_receiver).unwrap(); for event in signals { match event { SignalEvent::Resize => { // throttle sigwinch_cb calls, reduce excessive renders while resizing if sigwinch_cb_timestamp.elapsed() < SIGWINCH_CB_THROTTLE_DURATION { thread::sleep(SIGWINCH_CB_THROTTLE_DURATION); } sigwinch_cb_timestamp = time::Instant::now(); sigwinch_cb(); }, SignalEvent::Quit => { quit_cb(); break; }, } } } fn connect_to_server(&self, path: &Path) { let socket; loop { match zellij_utils::consts::ipc_connect(path) { Ok(sock) => { socket = sock; break; }, Err(_) => { std::thread::sleep(std::time::Duration::from_millis(50)); }, } } let (sender, receiver) = setup_ipc(socket, path); *self.send_instructions_to_server.lock().unwrap() = Some(sender); *self.receive_instructions_from_server.lock().unwrap() = Some(receiver); } fn load_palette(&self) -> Palette { // this was removed because termbg doesn't release stdin in certain scenarios (we know of // windows terminal and FreeBSD): https://github.com/zellij-org/zellij/issues/538 // // let palette = default_palette(); // let timeout = std::time::Duration::from_millis(100); // if let Ok(rgb) = termbg::rgb(timeout) { // palette.bg = PaletteColor::Rgb((rgb.r as u8, rgb.g as u8, rgb.b as u8)); // // TODO: also dynamically get all other colors from the user's terminal // // this should be done in the same method (OSC ]11), but there might be other // // considerations here, hence using the library // }; default_palette() } fn enable_mouse(&self) -> Result<()> { let mut stdout = self.get_stdout_writer(); enable_mouse_support(&mut *stdout) } fn disable_mouse(&self) -> Result<()> { let mut stdout = self.get_stdout_writer(); disable_mouse_support(&mut *stdout) } #[cfg(windows)] fn restore_console_mode(&self) { restore_console_mode(); } fn env_variable(&self, name: &str) -> Option { std::env::var(name).ok() } } impl Clone for Box { fn clone(&self) -> Box { self.box_clone() } } pub fn get_client_os_input() -> Result { let reading_from_stdin = Arc::new(Mutex::new(None)); Ok(ClientOsInputOutput { send_instructions_to_server: Arc::new(Mutex::new(None)), receive_instructions_from_server: Arc::new(Mutex::new(None)), reading_from_stdin, session_name: Arc::new(Mutex::new(None)), }) } pub fn get_cli_client_os_input() -> Result { let reading_from_stdin = Arc::new(Mutex::new(None)); Ok(ClientOsInputOutput { send_instructions_to_server: Arc::new(Mutex::new(None)), receive_instructions_from_server: Arc::new(Mutex::new(None)), reading_from_stdin, session_name: Arc::new(Mutex::new(None)), }) } pub const DEFAULT_STDIN_POLL_TIMEOUT_MS: u64 = 10; #[cfg(test)] mod tests { use super::*; #[test] fn get_terminal_size_returns_nonzero_or_fallback() { let size = get_terminal_size(); // In CI or when not attached to a terminal, crossterm may return an error // and we fall back to 80x24. Either way, size should be valid. assert!(size.rows > 0, "rows should be positive"); assert!(size.cols > 0, "cols should be positive"); } #[test] fn get_terminal_size_fallback_values() { // Verify the fallback constants are what we expect let fallback = Size { rows: 24, cols: 80 }; // When crossterm::terminal::size() fails (no terminal), we should get 80x24 // This is implicitly tested by get_terminal_size_returns_nonzero_or_fallback // but we verify the constants here assert_eq!(fallback.rows, 24); assert_eq!(fallback.cols, 80); } #[test] fn client_os_input_output_can_be_constructed() { let os_input = get_client_os_input().expect("should construct ClientOsInputOutput"); let size = os_input.get_terminal_size(); assert!(size.rows > 0, "rows should be positive"); assert!(size.cols > 0, "cols should be positive"); } #[test] fn cli_client_os_input_can_be_constructed() { let os_input = get_cli_client_os_input().expect("should construct CLI ClientOsInputOutput"); let size = os_input.get_terminal_size(); assert!(size.rows > 0, "rows should be positive"); assert!(size.cols > 0, "cols should be positive"); } }