// Support for exposing the terminal size. use crate::common::assert_sync; use crate::env::{EnvMode, EnvVar, Environment}; use crate::flog::FLOG; use crate::parser::Parser; #[cfg(test)] use crate::tests::prelude::*; use crate::wchar::prelude::*; use crate::wutil::fish_wcstoi; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::Mutex; #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct Termsize { /// Width of the terminal, in columns. // TODO: Change to u32 pub width: isize, /// Height of the terminal, in rows. // TODO: Change to u32 pub height: isize, } // A counter which is incremented every SIGWINCH, or when the tty is otherwise invalidated. static TTY_TERMSIZE_GEN_COUNT: AtomicU32 = AtomicU32::new(0); /// Convert an environment variable to an int, or return a default value. /// The int must be >0 and , default: isize) -> isize { let val: WString = var.map(|v| v.as_string()).unwrap_or_default(); if !val.is_empty() { if let Ok(proposed) = fish_wcstoi(&val) { if proposed > 0 && proposed <= u16::MAX as i32 { return proposed as isize; } } } default } /// \return a termsize from ioctl, or None on error or if not supported. fn read_termsize_from_tty() -> Option { let mut ret: Option = None; // Note: historically we've supported libc::winsize not existing. let mut winsize: libc::winsize = unsafe { std::mem::zeroed() }; if unsafe { libc::ioctl(0, libc::TIOCGWINSZ, &mut winsize as *mut libc::winsize) } >= 0 { // 0 values are unusable, fall back to the default instead. if winsize.ws_col == 0 { FLOG!( term_support, L!("Terminal has 0 columns, falling back to default width") ); winsize.ws_col = Termsize::DEFAULT_WIDTH as u16; } if winsize.ws_row == 0 { FLOG!( term_support, L!("Terminal has 0 rows, falling back to default height") ); winsize.ws_row = Termsize::DEFAULT_HEIGHT as u16; } ret = Some(Termsize::new( winsize.ws_col as isize, winsize.ws_row as isize, )); } ret } impl Termsize { /// Default width and height. pub const DEFAULT_WIDTH: isize = 80; pub const DEFAULT_HEIGHT: isize = 24; /// Construct from width and height. pub fn new(width: isize, height: isize) -> Self { Self { width, height } } /// Return a default-sized termsize. pub fn defaults() -> Self { Self::new(Self::DEFAULT_WIDTH, Self::DEFAULT_HEIGHT) } } struct TermsizeData { // The last termsize returned by TIOCGWINSZ, or none if none. last_from_tty: Option, // The last termsize seen from the environment (COLUMNS/LINES), or none if none. last_from_env: Option, // The last-seen tty-invalidation generation count. // Set to a huge value so it's initially stale. last_tty_gen_count: u32, } impl TermsizeData { const fn defaults() -> Self { Self { last_from_tty: None, last_from_env: None, last_tty_gen_count: u32::max_value(), } } /// \return the current termsize from this data. fn current(&self) -> Termsize { // This encapsulates our ordering logic. If we have a termsize from a tty, use it; otherwise use // what we have seen from the environment. self.last_from_tty .or(self.last_from_env) .unwrap_or_else(Termsize::defaults) } /// Mark that our termsize is (for the time being) from the environment, not the tty. fn mark_override_from_env(&mut self, ts: Termsize) { self.last_from_env = Some(ts); self.last_from_tty = None; self.last_tty_gen_count = TTY_TERMSIZE_GEN_COUNT.load(Ordering::Relaxed); } } /// Termsize monitoring is more complicated than one may think. /// The main source of complexity is the interaction between the environment variables COLUMNS/ROWS, /// the WINCH signal, and the TIOCGWINSZ ioctl. /// Our policy is "last seen wins": if COLUMNS or LINES is modified, we respect that until we get a /// SIGWINCH. pub struct TermsizeContainer { // Our lock-protected data. data: Mutex, // An indication that we are currently in the process of setting COLUMNS and LINES, and so do // not react to any changes. setting_env_vars: AtomicBool, /// A function used for accessing the termsize from the tty. This is only exposed for testing. tty_size_reader: fn() -> Option, } impl TermsizeContainer { /// \return the termsize without applying any updates. /// Return the default termsize if none. pub fn last(&self) -> Termsize { self.data.lock().unwrap().current() } /// Initialize our termsize, using the given environment stack. /// This will prefer to use COLUMNS and LINES, but will fall back to the tty size reader. /// This does not change any variables in the environment. pub fn initialize(&self, vars: &dyn Environment) -> Termsize { let new_termsize = Termsize { width: var_to_int_or(vars.getf(L!("COLUMNS"), EnvMode::GLOBAL), -1), height: var_to_int_or(vars.getf(L!("LINES"), EnvMode::GLOBAL), -1), }; let mut data = self.data.lock().unwrap(); if new_termsize.width > 0 && new_termsize.height > 0 { data.mark_override_from_env(new_termsize); } else { data.last_tty_gen_count = TTY_TERMSIZE_GEN_COUNT.load(Ordering::Relaxed); data.last_from_tty = (self.tty_size_reader)(); } data.current() } /// If our termsize is stale, update it, using \p parser to fire any events that may be /// registered for COLUMNS and LINES. /// This requires a shared reference so it can work from a static. /// \return the updated termsize. pub fn updating(&self, parser: &Parser) -> Termsize { let new_size; let prev_size; // Take the lock in a local region. // Capture the size before and the new size. { let mut data = self.data.lock().unwrap(); prev_size = data.current(); // Critical read of signal-owned variable. // This must happen before the TIOCGWINSZ ioctl. let tty_gen_count: u32 = TTY_TERMSIZE_GEN_COUNT.load(Ordering::Relaxed); if data.last_tty_gen_count != tty_gen_count { // Our idea of the size of the terminal may be stale. // Apply any updates. data.last_tty_gen_count = tty_gen_count; data.last_from_tty = (self.tty_size_reader)(); } new_size = data.current(); } // Announce any updates. if new_size != prev_size { self.set_columns_lines_vars(new_size, parser); } new_size } fn set_columns_lines_vars(&self, val: Termsize, parser: &Parser) { let saved = self.setting_env_vars.swap(true, Ordering::Relaxed); parser.set_var_and_fire(L!("COLUMNS"), EnvMode::GLOBAL, vec![val.width.to_wstring()]); parser.set_var_and_fire(L!("LINES"), EnvMode::GLOBAL, vec![val.height.to_wstring()]); self.setting_env_vars.store(saved, Ordering::Relaxed); } /// Note that COLUMNS and/or LINES global variables changed. fn handle_columns_lines_var_change(&self, vars: &dyn Environment) { // Do nothing if we are the ones setting it. if self.setting_env_vars.load(Ordering::Relaxed) { return; } // Construct a new termsize from COLUMNS and LINES, then set it in our data. let new_termsize = Termsize { width: vars .getf(L!("COLUMNS"), EnvMode::GLOBAL) .map(|v| v.as_string()) .and_then(|v| fish_wcstoi(&v).ok().map(|h| h as isize)) .unwrap_or(Termsize::DEFAULT_WIDTH), height: vars .getf(L!("LINES"), EnvMode::GLOBAL) .map(|v| v.as_string()) .and_then(|v| fish_wcstoi(&v).ok().map(|h| h as isize)) .unwrap_or(Termsize::DEFAULT_HEIGHT), }; // Store our termsize as an environment override. self.data .lock() .unwrap() .mark_override_from_env(new_termsize); } /// Note that a WINCH signal is received. /// Naturally this may be called from within a signal handler. pub fn handle_winch() { TTY_TERMSIZE_GEN_COUNT.fetch_add(1, Ordering::Relaxed); } pub fn invalidate_tty() { TTY_TERMSIZE_GEN_COUNT.fetch_add(1, Ordering::Relaxed); } } pub static SHARED_CONTAINER: TermsizeContainer = TermsizeContainer { data: Mutex::new(TermsizeData::defaults()), setting_env_vars: AtomicBool::new(false), tty_size_reader: read_termsize_from_tty, }; const _: () = assert_sync::(); /// Helper to return the default termsize. pub fn termsize_default() -> Termsize { Termsize::defaults() } /// Convenience helper to return the last known termsize. pub fn termsize_last() -> Termsize { return SHARED_CONTAINER.last(); } /// Called when the COLUMNS or LINES variables are changed. pub fn handle_columns_lines_var_change(vars: &dyn Environment) { SHARED_CONTAINER.handle_columns_lines_var_change(vars); } pub fn termsize_update(parser: &Parser) -> Termsize { SHARED_CONTAINER.updating(parser) } pub fn termsize_invalidate_tty() { TermsizeContainer::invalidate_tty(); } #[test] #[serial] fn test_termsize() { test_init(); let env_global = EnvMode::GLOBAL; let parser = Parser::principal_parser(); let vars = parser.vars(); // Use a static variable so we can pretend we're the kernel exposing a terminal size. static STUBBY_TERMSIZE: Mutex> = Mutex::new(None); fn stubby_termsize() -> Option { *STUBBY_TERMSIZE.lock().unwrap() } let ts = TermsizeContainer { data: Mutex::new(TermsizeData::defaults()), setting_env_vars: AtomicBool::new(false), tty_size_reader: stubby_termsize, }; // Initially default value. assert_eq!(ts.last(), Termsize::defaults()); // Haha we change the value, it doesn't even know. *STUBBY_TERMSIZE.lock().unwrap() = Some(Termsize { width: 42, height: 84, }); assert_eq!(ts.last(), Termsize::defaults()); // Ok let's tell it. But it still doesn't update right away. TermsizeContainer::handle_winch(); assert_eq!(ts.last(), Termsize::defaults()); // Ok now we tell it to update. ts.updating(parser); assert_eq!(ts.last(), Termsize::new(42, 84)); assert_eq!(vars.get(L!("COLUMNS")).unwrap().as_string(), "42"); assert_eq!(vars.get(L!("LINES")).unwrap().as_string(), "84"); // Wow someone set COLUMNS and LINES to a weird value. // Now the tty's termsize doesn't matter. vars.set_one(L!("COLUMNS"), env_global, L!("75").to_owned()); vars.set_one(L!("LINES"), env_global, L!("150").to_owned()); ts.handle_columns_lines_var_change(parser.vars()); assert_eq!(ts.last(), Termsize::new(75, 150)); assert_eq!(vars.get(L!("COLUMNS")).unwrap().as_string(), "75"); assert_eq!(vars.get(L!("LINES")).unwrap().as_string(), "150"); vars.set_one(L!("COLUMNS"), env_global, L!("33").to_owned()); ts.handle_columns_lines_var_change(parser.vars()); assert_eq!(ts.last(), Termsize::new(33, 150)); // Oh it got SIGWINCH, now the tty matters again. TermsizeContainer::handle_winch(); assert_eq!(ts.last(), Termsize::new(33, 150)); assert_eq!(ts.updating(parser), stubby_termsize().unwrap()); assert_eq!(vars.get(L!("COLUMNS")).unwrap().as_string(), "42"); assert_eq!(vars.get(L!("LINES")).unwrap().as_string(), "84"); // Test initialize(). vars.set_one(L!("COLUMNS"), env_global, L!("83").to_owned()); vars.set_one(L!("LINES"), env_global, L!("38").to_owned()); ts.initialize(vars); assert_eq!(ts.last(), Termsize::new(83, 38)); // initialize() even beats the tty reader until a sigwinch. let ts2 = TermsizeContainer { data: Mutex::new(TermsizeData::defaults()), setting_env_vars: AtomicBool::new(false), tty_size_reader: stubby_termsize, }; ts.initialize(parser.vars()); ts2.updating(parser); assert_eq!(ts.last(), Termsize::new(83, 38)); TermsizeContainer::handle_winch(); assert_eq!(ts2.updating(parser), stubby_termsize().unwrap()); }