diff --git a/Cargo.toml b/Cargo.toml index d901462..7ebf308 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,10 @@ once_cell = "1.18.0" strsim = "0.11.1" textwrap = "0.16.0" zeroize = {version = "1.6.0", features = ["derive"]} +termsize = "0.1" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" [dev-dependencies] ctrlc = "3.4.2" diff --git a/examples/window_rows.rs b/examples/window_rows.rs new file mode 100644 index 0000000..63da4af --- /dev/null +++ b/examples/window_rows.rs @@ -0,0 +1,19 @@ +fn main() -> std::io::Result<()> { + let mut items: Vec<(String, String, String)> = Vec::new(); + + for i in 0..20 { + items.push((format!("Item {}", i), i.to_string(), format!("Hint {}", i))); + } + + // Try this example with a terminal height both less than and greater than 10 + // to see the automatic window-size adjustment. + let selected = cliclack::select("Select an item") + .items(&items) + .set_max_rows(10) // Specify a custom window-size + .filter_mode() // Try filtering on "1" + .interact()?; + + cliclack::outro(format!("You selected: {}", selected))?; + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 8d2aabf..10998c0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -250,7 +250,7 @@ //! cargo run --example theme //! ``` -#![forbid(unsafe_code)] +// #![forbid(unsafe_code)] #![warn(missing_docs, unused_qualifications)] mod confirm; diff --git a/src/multiselect.rs b/src/multiselect.rs index ddec3ab..835facf 100644 --- a/src/multiselect.rs +++ b/src/multiselect.rs @@ -4,6 +4,7 @@ use std::{fmt::Display, rc::Rc}; use console::Key; +use crate::prompt::term::TermSize; use crate::{ filter::{FilteredView, LabeledItem}, prompt::{ @@ -35,6 +36,7 @@ pub struct MultiSelect { initial_values: Option>, required: bool, filter: FilteredView>, + term: TermSize, } impl MultiSelect @@ -50,6 +52,7 @@ where initial_values: None, required: true, filter: FilteredView::default(), + term: TermSize::default(), } } @@ -89,10 +92,19 @@ where /// /// The filter mode allows to filter the items by typing. pub fn filter_mode(mut self) -> Self { + let term_size = self.term.get_max_rows(); + self.term + .set_max_rows(term_size.checked_sub(1).unwrap_or(term_size)); self.filter.enable(); self } + /// Set the max number of rows of items that are able to be displayed at once + pub fn set_max_rows(mut self, size: usize) -> Self { + self.term.set_max_rows(size); + self + } + /// Starts the prompt interaction. pub fn interact(&mut self) -> io::Result> { if self.items.is_empty() { @@ -129,11 +141,19 @@ impl PromptInteraction> for MultiSelect { if self.cursor > 0 { self.cursor -= 1; } + + if self.cursor < self.term.get_pos() { + self.term.set_pos(self.cursor); + } } Key::ArrowRight | Key::ArrowDown | Key::Char('j') | Key::Char('l') => { if !self.filter.items().is_empty() && self.cursor < self.filter.items().len() - 1 { self.cursor += 1; } + + if self.cursor >= self.term.get_pos() + self.term.get_max_rows() { + self.term.set_pos(self.cursor - self.term.get_max_rows() + 1); + } } Key::Char(' ') => { let mut item = self.filter.items()[self.cursor].borrow_mut(); @@ -185,7 +205,13 @@ impl PromptInteraction> for MultiSelect { }; let mut items_render = String::new(); - for (i, item) in items_to_render.iter().map(|i| i.borrow()).enumerate() { + for (i, item) in items_to_render + .iter() + .map(|i| i.borrow()) + .enumerate() + .skip(self.term.get_pos()) + .take(self.term.get_max_rows()) + { items_render.push_str(&theme.format_multiselect_item( &state.into(), item.selected, diff --git a/src/prompt/mod.rs b/src/prompt/mod.rs index bf047d7..60e0e45 100644 --- a/src/prompt/mod.rs +++ b/src/prompt/mod.rs @@ -1,2 +1,3 @@ pub mod cursor; pub mod interaction; +pub mod term; diff --git a/src/prompt/term.rs b/src/prompt/term.rs new file mode 100644 index 0000000..6de35c5 --- /dev/null +++ b/src/prompt/term.rs @@ -0,0 +1,125 @@ +pub(crate) struct TermSize { + window_max_rows: usize, + window_pos: usize, +} + +impl Default for TermSize { + fn default() -> Self { + let mut window_max_rows = usize::MAX; + + if let Some(termsize) = get_term_size() { + window_max_rows = (termsize.rows as usize) + .checked_sub(3) + .unwrap_or(termsize.rows as usize); + } + + Self { + window_max_rows, + window_pos: 0, + } + } +} + +impl TermSize { + pub fn get_max_rows(&self) -> usize { + self.window_max_rows + } + + pub fn set_max_rows(&mut self, rows: usize) { + self.window_max_rows = rows; + } + + pub fn get_pos(&self) -> usize { + self.window_pos + } + + pub fn set_pos(&mut self, pos: usize) { + self.window_pos = pos; + } +} + +// IMPORTANT - Everything bellow this should be removed once +// https://github.com/softprops/termsize/pull/24 is merged! +// and the forbid unsafe rule should be reenabled! + +#[cfg(unix)] +use std::io::IsTerminal; + +#[cfg(unix)] +use std::ffi::{c_ushort, CString}; + +#[cfg(unix)] +use libc::{ioctl, O_RDONLY, STDOUT_FILENO, TIOCGWINSZ}; + +/// A representation of the size of the current terminal +#[repr(C)] +#[derive(Debug)] +#[cfg(unix)] +pub struct UnixSize { + /// number of rows + pub rows: c_ushort, + /// number of columns + pub cols: c_ushort, + x: c_ushort, + y: c_ushort, +} + + +/// Workaround for SSH terminal size +pub fn get_term_size() -> Option { + #[cfg(not(unix))] + { + termsize::get() + } + + #[cfg(unix)] + { + _get_unix_termsize() + } +} + +/// Gets the current terminal size +#[cfg(unix)] +fn _get_unix_termsize() -> Option { + // http://rosettacode.org/wiki/Terminal_control/Dimensions#Library:_BSD_libc + if !std::io::stdout().is_terminal() { + return None; + } + let mut us = UnixSize { + rows: 0, + cols: 0, + x: 0, + y: 0, + }; + + let fd = if let Ok(ssh_term) = std::env::var("SSH_TTY") { + // Convert path to a C-compatible string + let c_path = CString::new(ssh_term).expect("Failed to convert path to CString"); + + // Open the terminal device + let fd = unsafe { libc::open(c_path.as_ptr(), O_RDONLY) }; + if fd < 0 { + return None; // Failed to open the terminal device + } + + fd + } else { + STDOUT_FILENO + }; + + let r = unsafe { ioctl(fd, TIOCGWINSZ, &mut us) }; + + // Closing the open file descriptor + if fd != STDOUT_FILENO { + unsafe { libc::close(fd); } + } + + if r == 0 { + Some(termsize::Size { + rows: us.rows, + cols: us.cols, + }) + } else { + None + } +} diff --git a/src/select.rs b/src/select.rs index 47a62c0..ff0ba6b 100644 --- a/src/select.rs +++ b/src/select.rs @@ -4,6 +4,7 @@ use std::{fmt::Display, rc::Rc}; use console::Key; +use crate::prompt::term::TermSize; use crate::{ filter::{FilteredView, LabeledItem}, prompt::{ @@ -33,6 +34,7 @@ pub struct Select { cursor: usize, initial_value: Option, filter: FilteredView>, + term: TermSize, } impl Select @@ -47,6 +49,7 @@ where cursor: 0, initial_value: None, filter: FilteredView::default(), + term: TermSize::default(), } } @@ -78,10 +81,19 @@ where /// /// The filter mode allows to filter the items by typing. pub fn filter_mode(mut self) -> Self { + let term_size = self.term.get_max_rows(); + self.term + .set_max_rows(term_size.checked_sub(1).unwrap_or(term_size as usize)); self.filter.enable(); self } + /// Set the max number of rows of items that are able to be displayed at once + pub fn set_max_rows(mut self, size: usize) -> Self { + self.term.set_max_rows(size); + self + } + /// Starts the prompt interaction. pub fn interact(&mut self) -> io::Result { if self.items.is_empty() { @@ -118,11 +130,19 @@ impl PromptInteraction for Select { if self.cursor > 0 { self.cursor -= 1; } + + if self.cursor < self.term.get_pos() { + self.term.set_pos(self.cursor); + } } Key::ArrowDown | Key::ArrowRight | Key::Char('j') | Key::Char('l') => { if !self.filter.items().is_empty() && self.cursor < self.filter.items().len() - 1 { self.cursor += 1; } + + if self.cursor >= self.term.get_pos() + self.term.get_max_rows() { + self.term.set_pos(self.cursor - self.term.get_max_rows() + 1); + } } Key::Enter => { return State::Submit(self.filter.items()[self.cursor].borrow().value.clone()); @@ -153,6 +173,8 @@ impl PromptInteraction for Select { .items() .iter() .enumerate() + .skip(self.term.get_pos()) + .take(self.term.get_max_rows()) .map(|(i, item)| { let item = item.borrow(); theme.format_select_item(&state.into(), self.cursor == i, &item.label, &item.hint)