diff options
| author | divens <divens@users.noreply.github.com> | 2026-02-18 15:07:19 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-18 15:07:19 +0100 |
| commit | 36fa696c1a71b236e27d62bac26e4f0ee136cde4 (patch) | |
| tree | d46e371641be07247c5f825a698e90b48d23c64e | |
| parent | refactor: replace nix::Error with std::io::Error in public APIs (#4720) (diff) | |
| download | sl0ppij-36fa696c1a71b236e27d62bac26e4f0ee136cde4.tar.gz sl0ppij-36fa696c1a71b236e27d62bac26e4f0ee136cde4.tar.xz sl0ppij-36fa696c1a71b236e27d62bac26e4f0ee136cde4.zip | |
[Windows port PR2] Replace client-side termios/ioctl with crossterm for raw mode and terminal size (#4721)
* refactor: replace nix::Error with std::io::Error in public APIs
* refactor: replace client-side termios/ioctl with crossterm
---------
Co-authored-by: divens <divens.dev@gmail.com>
| -rw-r--r-- | Cargo.lock | 12 | ||||
| -rw-r--r-- | Cargo.toml | 1 | ||||
| -rw-r--r-- | zellij-client/Cargo.toml | 1 | ||||
| -rw-r--r-- | zellij-client/src/lib.rs | 32 | ||||
| -rw-r--r-- | zellij-client/src/os_input_output.rs | 147 | ||||
| -rw-r--r-- | zellij-client/src/remote_attach/unit/remote_attach_tests.rs | 6 | ||||
| -rw-r--r-- | zellij-client/src/unit/terminal_loop_tests.rs | 7 | ||||
| -rw-r--r-- | zellij-client/src/web_client/server_listener.rs | 2 | ||||
| -rw-r--r-- | zellij-client/src/web_client/unit/web_client_tests.rs | 6 |
9 files changed, 103 insertions, 111 deletions
@@ -943,6 +943,17 @@ dependencies = [ ] [[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.5.0", + "parking_lot 0.12.1", + "rustix 0.38.44", +] + +[[package]] name = "crypto-common" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5326,6 +5337,7 @@ dependencies = [ "axum", "axum-extra", "axum-server", + "crossterm", "daemonize", "dialoguer", "futures", @@ -75,6 +75,7 @@ anyhow = { version = "1.0.70", default-features = false, features = ["backtrace" async-std = { version = "1.3.0", default-features = false, features = ["attributes", "default", "std", "unstable"] } async-trait = { version = "0.1", default-features = false } clap = { version = "3.2.2", default-features = false, features = ["env", "derive", "color", "std", "suggestions"] } +crossterm = { version = "0.28", default-features = false } daemonize = { version = "0.5", default-features = false } dialoguer = { version = "0.10.4", default-features = false, features = ["password", "completion"] } humantime = { version = "2.1.0", default-features = false } diff --git a/zellij-client/Cargo.toml b/zellij-client/Cargo.toml index 6f0b90e5..655f0f04 100644 --- a/zellij-client/Cargo.toml +++ b/zellij-client/Cargo.toml @@ -21,6 +21,7 @@ tower-http = { version = "0.6.4", features = ["cors"], optional = true } anyhow = { workspace = true } async-trait = { workspace = true } +crossterm = { workspace = true } daemonize = { workspace = true } dialoguer = { workspace = true } interprocess = { workspace = true } diff --git a/zellij-client/src/lib.rs b/zellij-client/src/lib.rs index 4061b690..e7b6bc97 100644 --- a/zellij-client/src/lib.rs +++ b/zellij-client/src/lib.rs @@ -300,7 +300,7 @@ pub async fn run_remote_client_terminal_loop( }; // send size on startup - let new_size = os_input.get_terminal_size_using_fd(0); + let new_size = os_input.get_terminal_size(); if let Err(e) = connections .control_ws .send(create_resize_message(new_size)) @@ -335,7 +335,7 @@ pub async fn run_remote_client_terminal_loop( Some(signal) = async_signals.recv() => { match signal { crate::os_input_output::SignalEvent::Resize => { - let new_size = os_input.get_terminal_size_using_fd(0); + let new_size = os_input.get_terminal_size(); if let Err(e) = connections.control_ws.send(create_resize_message(new_size)).await { log::error!("Failed to send resize message: {}", e); break; @@ -409,7 +409,7 @@ pub async fn run_remote_client_terminal_loop( // no-op } Ok(WebServerToWebClientControlMessage::QueryTerminalSize) => { - let new_size = os_input.get_terminal_size_using_fd(0); + let new_size = os_input.get_terminal_size(); if let Err(e) = connections.control_ws.send(create_resize_message(new_size)).await { log::error!("Failed to send resize message: {}", e); } @@ -477,7 +477,7 @@ pub fn start_remote_client( let take_snapshot = "\u{1b}[?1049h"; let bracketed_paste = "\u{1b}[?2004h"; let enter_kitty_keyboard_mode = "\u{1b}[>1u"; - os_input.unset_raw_mode(0).unwrap(); + os_input.unset_raw_mode().unwrap(); let _ = os_input .get_stdout_writer() @@ -494,9 +494,9 @@ pub fn start_remote_client( envs::set_zellij("0".to_string()); - let full_screen_ws = os_input.get_terminal_size_using_fd(0); + let full_screen_ws = os_input.get_terminal_size(); - os_input.set_raw_mode(0); + os_input.set_raw_mode(); let _ = os_input .get_stdout_writer() .write(bracketed_paste.as_bytes()) @@ -506,14 +506,14 @@ pub fn start_remote_client( use zellij_utils::errors::handle_panic; let os_input = os_input.clone(); Box::new(move |info| { - if let Ok(()) = os_input.unset_raw_mode(0) { + if let Ok(()) = os_input.unset_raw_mode() { handle_panic::<ClientInstruction>(info, None); } }) }); let reset_controlling_terminal_state = |e: String, exit_status: i32| { - os_input.unset_raw_mode(0).unwrap(); + os_input.unset_raw_mode().unwrap(); let goto_start_of_last_line = format!("\u{1b}[{};{}H", full_screen_ws.rows, 1); let restore_alternate_screen = "\u{1b}[?1049l"; let exit_kitty_keyboard_mode = "\u{1b}[<1u"; @@ -589,7 +589,7 @@ pub fn start_client( let take_snapshot = "\u{1b}[?1049h"; let bracketed_paste = "\u{1b}[?2004h"; let enter_kitty_keyboard_mode = "\u{1b}[>1u"; - os_input.unset_raw_mode(0).unwrap(); + os_input.unset_raw_mode().unwrap(); if !is_a_reconnect { // we don't do this for a reconnect because our controlling terminal already has the @@ -613,7 +613,7 @@ pub fn start_client( envs::set_zellij("0".to_string()); config.env.set_vars(); - let full_screen_ws = os_input.get_terminal_size_using_fd(0); + let full_screen_ws = os_input.get_terminal_size(); let web_server_ip = config_options .web_server_ip @@ -793,7 +793,7 @@ pub fn start_client( let mut command_is_executing = CommandIsExecuting::new(); - os_input.set_raw_mode(0); + os_input.set_raw_mode(); let _ = os_input .get_stdout_writer() .write(bracketed_paste.as_bytes()) @@ -814,7 +814,7 @@ pub fn start_client( let send_client_instructions = send_client_instructions.clone(); let os_input = os_input.clone(); Box::new(move |info| { - if let Ok(()) = os_input.unset_raw_mode(0) { + if let Ok(()) = os_input.unset_raw_mode() { handle_panic(info, Some(&send_client_instructions)); } }) @@ -869,7 +869,7 @@ pub fn start_client( let os_api = os_input.clone(); move || { os_api.send_to_server(ClientToServerMsg::TerminalResize { - new_size: os_api.get_terminal_size_using_fd(0), + new_size: os_api.get_terminal_size(), }); } }), @@ -929,7 +929,7 @@ pub fn start_client( .unwrap(); let handle_error = |backtrace: String| { - os_input.unset_raw_mode(0).unwrap(); + os_input.unset_raw_mode().unwrap(); let goto_start_of_last_line = format!("\u{1b}[{};{}H", full_screen_ws.rows, 1); let restore_snapshot = "\u{1b}[?1049l"; os_input.disable_mouse().non_fatal(); @@ -1073,7 +1073,7 @@ pub fn start_client( }, ClientInstruction::QueryTerminalSize => { os_input.send_to_server(ClientToServerMsg::TerminalResize { - new_size: os_input.get_terminal_size_using_fd(0), + new_size: os_input.get_terminal_size(), }); }, ClientInstruction::StartWebServer => { @@ -1114,7 +1114,7 @@ pub fn start_client( os_input.disable_mouse().non_fatal(); info!("{}", exit_msg); - os_input.unset_raw_mode(0).unwrap(); + os_input.unset_raw_mode().unwrap(); let mut stdout = os_input.get_stdout_writer(); let exit_kitty_keyboard_mode = "\u{1b}[<1u"; if !explicitly_disable_kitty_keyboard_protocol { diff --git a/zellij-client/src/os_input_output.rs b/zellij-client/src/os_input_output.rs index 57c351e5..b1f95fa4 100644 --- a/zellij-client/src/os_input_output.rs +++ b/zellij-client/src/os_input_output.rs @@ -1,19 +1,13 @@ use anyhow::{Context, Result}; use async_trait::async_trait; use interprocess; -use libc; -use nix; use signal_hook; use zellij_utils::pane_size::Size; use interprocess::local_socket::LocalSocketStream; -use mio::{unix::SourceFd, Events, Interest, Poll, Token}; -use nix::pty::Winsize; -use nix::sys::termios; use signal_hook::{consts::signal::*, iterator::Signals}; use std::io::prelude::*; use std::io::IsTerminal; -use std::os::unix::io::RawFd; use std::path::Path; use std::sync::{Arc, Mutex}; use std::{io, thread, time}; @@ -118,62 +112,20 @@ impl AsyncSignals for AsyncSignalListener { } } -fn into_raw_mode(pid: RawFd) { - let mut tio = termios::tcgetattr(pid).expect("could not get terminal attribute"); - termios::cfmakeraw(&mut tio); - match termios::tcsetattr(pid, termios::SetArg::TCSANOW, &tio) { - Ok(_) => {}, - Err(e) => panic!("error {:?}", e), - }; -} - -fn unset_raw_mode(pid: RawFd, orig_termios: termios::Termios) -> Result<(), std::io::Error> { - Ok(termios::tcsetattr( - pid, - termios::SetArg::TCSANOW, - &orig_termios, - )?) -} - -pub(crate) fn get_terminal_size_using_fd(fd: RawFd) -> Size { - // TODO: do this with the nix ioctl - use libc::ioctl; - use libc::TIOCGWINSZ; - - let mut winsize = Winsize { - ws_row: 0, - ws_col: 0, - ws_xpixel: 0, - ws_ypixel: 0, - }; - - // TIOCGWINSZ is an u32, but the second argument to ioctl is u64 on - // some platforms. When checked on Linux, clippy will complain about - // useless conversion. - #[allow(clippy::useless_conversion)] - unsafe { - ioctl(fd, TIOCGWINSZ.into(), &mut winsize) - }; - - // fallback to default values when rows/cols == 0: https://github.com/zellij-org/zellij/issues/1551 - let rows = if winsize.ws_row != 0 { - winsize.ws_row as usize - } else { - 24 - }; - - let cols = if winsize.ws_col != 0 { - winsize.ws_col as usize - } else { - 80 - }; - - Size { rows, cols } +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 { - orig_termios: Option<Arc<Mutex<termios::Termios>>>, send_instructions_to_server: Arc<Mutex<Option<IpcSenderWithContext<ClientToServerMsg>>>>, receive_instructions_from_server: Arc<Mutex<Option<IpcReceiverWithContext<ServerToClientMsg>>>>, reading_from_stdin: Arc<Mutex<Option<Vec<u8>>>>, @@ -189,14 +141,14 @@ impl std::fmt::Debug for ClientOsInputOutput { /// 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 associated to file descriptor `fd`. - fn get_terminal_size_using_fd(&self, fd: RawFd) -> Size; - /// Set the terminal associated to file descriptor `fd` to + /// 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, fd: RawFd); - /// Set the terminal associated to file descriptor `fd` to + fn set_raw_mode(&mut self); + /// Set the terminal to /// [cooked mode](https://en.wikipedia.org/wiki/Terminal_mode). - fn unset_raw_mode(&self, fd: RawFd) -> Result<(), std::io::Error>; + fn unset_raw_mode(&self) -> Result<(), std::io::Error>; /// Returns the writer that allows writing to standard output. fn get_stdout_writer(&self) -> Box<dyn io::Write>; /// Returns a BufReader that allows to read from STDIN line by line, also locks STDIN @@ -237,23 +189,14 @@ pub trait ClientOsApi: Send + Sync + std::fmt::Debug { } impl ClientOsApi for ClientOsInputOutput { - fn get_terminal_size_using_fd(&self, fd: RawFd) -> Size { - get_terminal_size_using_fd(fd) + fn get_terminal_size(&self) -> Size { + get_terminal_size() } - fn set_raw_mode(&mut self, fd: RawFd) { - into_raw_mode(fd); + fn set_raw_mode(&mut self) { + crossterm::terminal::enable_raw_mode().expect("could not enable raw mode"); } - fn unset_raw_mode(&self, fd: RawFd) -> Result<(), std::io::Error> { - match &self.orig_termios { - Some(orig_termios) => { - let orig_termios = orig_termios.lock().unwrap(); - unset_raw_mode(fd, orig_termios.clone()) - }, - None => { - log::warn!("trying to unset raw mode for a non-terminal session"); - Ok(()) - }, - } + fn unset_raw_mode(&self) -> Result<(), std::io::Error> { + crossterm::terminal::disable_raw_mode() } fn box_clone(&self) -> Box<dyn ClientOsApi> { Box::new((*self).clone()) @@ -422,11 +365,8 @@ impl Clone for Box<dyn ClientOsApi> { } pub fn get_client_os_input() -> Result<ClientOsInputOutput, std::io::Error> { - let current_termios = termios::tcgetattr(0).ok(); - let orig_termios = current_termios.map(|termios| Arc::new(Mutex::new(termios))); let reading_from_stdin = Arc::new(Mutex::new(None)); Ok(ClientOsInputOutput { - orig_termios, send_instructions_to_server: Arc::new(Mutex::new(None)), receive_instructions_from_server: Arc::new(Mutex::new(None)), reading_from_stdin, @@ -435,10 +375,8 @@ pub fn get_client_os_input() -> Result<ClientOsInputOutput, std::io::Error> { } pub fn get_cli_client_os_input() -> Result<ClientOsInputOutput, std::io::Error> { - let orig_termios = None; // not a terminal let reading_from_stdin = Arc::new(Mutex::new(None)); Ok(ClientOsInputOutput { - orig_termios, send_instructions_to_server: Arc::new(Mutex::new(None)), receive_instructions_from_server: Arc::new(Mutex::new(None)), reading_from_stdin, @@ -447,3 +385,44 @@ pub fn get_cli_client_os_input() -> Result<ClientOsInputOutput, std::io::Error> } 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"); + } +} diff --git a/zellij-client/src/remote_attach/unit/remote_attach_tests.rs b/zellij-client/src/remote_attach/unit/remote_attach_tests.rs index 8cccbc67..4673abb4 100644 --- a/zellij-client/src/remote_attach/unit/remote_attach_tests.rs +++ b/zellij-client/src/remote_attach/unit/remote_attach_tests.rs @@ -229,13 +229,13 @@ fn cleanup_test_db(server_url: &str) { struct MockClientOsApi; impl crate::os_input_output::ClientOsApi for MockClientOsApi { - fn get_terminal_size_using_fd(&self, _fd: i32) -> zellij_utils::pane_size::Size { + fn get_terminal_size(&self) -> zellij_utils::pane_size::Size { zellij_utils::pane_size::Size { rows: 24, cols: 80 } } - fn set_raw_mode(&mut self, _fd: i32) {} + fn set_raw_mode(&mut self) {} - fn unset_raw_mode(&self, _fd: i32) -> Result<(), std::io::Error> { + fn unset_raw_mode(&self) -> Result<(), std::io::Error> { Ok(()) } diff --git a/zellij-client/src/unit/terminal_loop_tests.rs b/zellij-client/src/unit/terminal_loop_tests.rs index 4d037cae..e43adb4b 100644 --- a/zellij-client/src/unit/terminal_loop_tests.rs +++ b/zellij-client/src/unit/terminal_loop_tests.rs @@ -9,7 +9,6 @@ use async_trait::async_trait; use futures_util::{SinkExt, StreamExt}; use serial_test::serial; use std::io::{self, Write}; -use std::os::unix::io::RawFd; use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio::sync::mpsc; @@ -77,13 +76,13 @@ impl std::fmt::Debug for TestClientOsApi { } impl ClientOsApi for TestClientOsApi { - fn get_terminal_size_using_fd(&self, _fd: RawFd) -> Size { + fn get_terminal_size(&self) -> Size { self.terminal_size } - fn set_raw_mode(&mut self, _fd: RawFd) {} + fn set_raw_mode(&mut self) {} - fn unset_raw_mode(&self, _fd: RawFd) -> Result<(), std::io::Error> { + fn unset_raw_mode(&self) -> Result<(), std::io::Error> { Ok(()) } diff --git a/zellij-client/src/web_client/server_listener.rs b/zellij-client/src/web_client/server_listener.rs index daad1233..83a770d4 100644 --- a/zellij-client/src/web_client/server_listener.rs +++ b/zellij-client/src/web_client/server_listener.rs @@ -64,7 +64,7 @@ pub fn zellij_server_listener( reload_config_from_disk(&mut config, &mut config_options, &config_file_path); - let full_screen_ws = os_input.get_terminal_size_using_fd(0); + let full_screen_ws = os_input.get_terminal_size(); let mut sent_init_messages = false; let palette = config diff --git a/zellij-client/src/web_client/unit/web_client_tests.rs b/zellij-client/src/web_client/unit/web_client_tests.rs index dde293cf..ccaf8e0b 100644 --- a/zellij-client/src/web_client/unit/web_client_tests.rs +++ b/zellij-client/src/web_client/unit/web_client_tests.rs @@ -1891,11 +1891,11 @@ impl MockClientOsApi { } impl ClientOsApi for MockClientOsApi { - fn get_terminal_size_using_fd(&self, _fd: std::os::unix::io::RawFd) -> Size { + fn get_terminal_size(&self) -> Size { self.terminal_size } - fn set_raw_mode(&mut self, _fd: std::os::unix::io::RawFd) {} - fn unset_raw_mode(&self, _fd: std::os::unix::io::RawFd) -> Result<(), std::io::Error> { + fn set_raw_mode(&mut self) {} + fn unset_raw_mode(&self) -> Result<(), std::io::Error> { Ok(()) } fn get_stdout_writer(&self) -> Box<dyn std::io::Write> { |
