From 875a7f9a721d4ad17dfc73dd7e5e9d5942f84aa4 Mon Sep 17 00:00:00 2001 From: Bjorn <75190918+BjornTheProgrammer@users.noreply.github.com> Date: Mon, 28 Oct 2024 05:37:00 -0700 Subject: [PATCH 1/5] Added accounting for terminal size --- Cargo.toml | 1 + examples/window_size.rs | 19 +++++++++++++++++++ src/multiselect.rs | 26 +++++++++++++++++++++++++- src/prompt/mod.rs | 1 + src/prompt/term.rs | 37 +++++++++++++++++++++++++++++++++++++ src/select.rs | 20 ++++++++++++++++++++ 6 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 examples/window_size.rs create mode 100644 src/prompt/term.rs diff --git a/Cargo.toml b/Cargo.toml index d901462..9a76930 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ once_cell = "1.18.0" strsim = "0.11.1" textwrap = "0.16.0" zeroize = {version = "1.6.0", features = ["derive"]} +termsize = "0.1" [dev-dependencies] ctrlc = "3.4.2" diff --git a/examples/window_size.rs b/examples/window_size.rs new file mode 100644 index 0000000..a6317fb --- /dev/null +++ b/examples/window_size.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) + .window_size(10) // Specify a custom window-size + .filter_mode() // Try filtering on "1" + .interact()?; + + cliclack::outro(format!("You selected: {}", selected))?; + + Ok(()) +} diff --git a/src/multiselect.rs b/src/multiselect.rs index ddec3ab..6d952de 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,17 @@ where /// /// The filter mode allows to filter the items by typing. pub fn filter_mode(mut self) -> Self { + self.term.set_size(self.term.get_size() - 1); self.filter.enable(); self } + /// Set the max number of items that are able to be displayed at once + pub fn set_size(mut self, size: usize) -> Self { + self.term.set_size(size); + self + } + /// Starts the prompt interaction. pub fn interact(&mut self) -> io::Result> { if self.items.is_empty() { @@ -129,11 +139,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_size() { + self.term.set_pos(self.cursor - self.term.get_size() + 1); + } } Key::Char(' ') => { let mut item = self.filter.items()[self.cursor].borrow_mut(); @@ -185,7 +203,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_size()) + { 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..9a5ee23 --- /dev/null +++ b/src/prompt/term.rs @@ -0,0 +1,37 @@ +pub(crate) struct TermSize { + window_size: usize, + window_pos: usize, +} + +impl Default for TermSize { + fn default() -> Self { + let mut window_size = usize::MAX; + + if let Some(termsize) = termsize::get() { + window_size = termsize.rows as usize - 3; + } + + Self { + window_size, + window_pos: 0, + } + } +} + +impl TermSize { + pub fn get_size(&self) -> usize { + self.window_size + } + + pub fn set_size(&mut self, size: usize) { + self.window_size = size; + } + + pub fn get_pos(&self) -> usize { + self.window_pos + } + + pub fn set_pos(&mut self, pos: usize) { + self.window_pos = pos; + } +} diff --git a/src/select.rs b/src/select.rs index 47a62c0..fc4e529 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,17 @@ where /// /// The filter mode allows to filter the items by typing. pub fn filter_mode(mut self) -> Self { + self.term.set_size(self.term.get_size() - 1); self.filter.enable(); self } + /// Set the max number of items that are able to be displayed at once + pub fn set_size(mut self, size: usize) -> Self { + self.term.set_size(size); + self + } + /// Starts the prompt interaction. pub fn interact(&mut self) -> io::Result { if self.items.is_empty() { @@ -118,11 +128,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_size() { + self.term.set_pos(self.cursor - self.term.get_size() + 1); + } } Key::Enter => { return State::Submit(self.filter.items()[self.cursor].borrow().value.clone()); @@ -153,6 +171,8 @@ impl PromptInteraction for Select { .items() .iter() .enumerate() + .skip(self.term.get_pos()) + .take(self.term.get_size()) .map(|(i, item)| { let item = item.borrow(); theme.format_select_item(&state.into(), self.cursor == i, &item.label, &item.hint) From 35a1882c601b90bf1398c3cb867cc6b20bbe9ce9 Mon Sep 17 00:00:00 2001 From: Bjorn <75190918+BjornTheProgrammer@users.noreply.github.com> Date: Sat, 2 Nov 2024 23:34:39 -0700 Subject: [PATCH 2/5] Fix example --- examples/window_size.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/window_size.rs b/examples/window_size.rs index a6317fb..256c109 100644 --- a/examples/window_size.rs +++ b/examples/window_size.rs @@ -9,7 +9,7 @@ fn main() -> std::io::Result<()> { // to see the automatic window-size adjustment. let selected = cliclack::select("Select an item") .items(&items) - .window_size(10) // Specify a custom window-size + .set_size(10) // Specify a custom window-size .filter_mode() // Try filtering on "1" .interact()?; From c324bf56a47c9f4df4580dff4de7d4c66fd051d8 Mon Sep 17 00:00:00 2001 From: Bjorn Beishline <75190918+BjornTheProgrammer@users.noreply.github.com> Date: Tue, 7 Jan 2025 10:46:27 -0700 Subject: [PATCH 3/5] fix: potential panics handled --- src/multiselect.rs | 3 ++- src/prompt/term.rs | 2 +- src/select.rs | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/multiselect.rs b/src/multiselect.rs index 6d952de..a1c871d 100644 --- a/src/multiselect.rs +++ b/src/multiselect.rs @@ -92,7 +92,8 @@ where /// /// The filter mode allows to filter the items by typing. pub fn filter_mode(mut self) -> Self { - self.term.set_size(self.term.get_size() - 1); + let term_size = self.term.get_size(); + self.term.set_size(term_size.checked_sub(1).unwrap_or(term_size)); self.filter.enable(); self } diff --git a/src/prompt/term.rs b/src/prompt/term.rs index 9a5ee23..9ad6680 100644 --- a/src/prompt/term.rs +++ b/src/prompt/term.rs @@ -8,7 +8,7 @@ impl Default for TermSize { let mut window_size = usize::MAX; if let Some(termsize) = termsize::get() { - window_size = termsize.rows as usize - 3; + window_size = (termsize.rows as usize).checked_sub(3).unwrap_or(termsize.rows as usize); } Self { diff --git a/src/select.rs b/src/select.rs index fc4e529..1d53af1 100644 --- a/src/select.rs +++ b/src/select.rs @@ -81,7 +81,8 @@ where /// /// The filter mode allows to filter the items by typing. pub fn filter_mode(mut self) -> Self { - self.term.set_size(self.term.get_size() - 1); + let term_size = self.term.get_size(); + self.term.set_size(term_size.checked_sub(1).unwrap_or(term_size as usize)); self.filter.enable(); self } From 5af7a8e88b47f685380e1df2b8df26484fa911bc Mon Sep 17 00:00:00 2001 From: Bjorn Beishline <75190918+BjornTheProgrammer@users.noreply.github.com> Date: Wed, 8 Jan 2025 11:44:48 -0700 Subject: [PATCH 4/5] Renamed size to max_rows --- examples/{window_size.rs => window_rows.rs} | 2 +- src/multiselect.rs | 17 +++++++++-------- src/prompt/term.rs | 18 ++++++++++-------- src/select.rs | 17 +++++++++-------- 4 files changed, 29 insertions(+), 25 deletions(-) rename examples/{window_size.rs => window_rows.rs} (90%) diff --git a/examples/window_size.rs b/examples/window_rows.rs similarity index 90% rename from examples/window_size.rs rename to examples/window_rows.rs index 256c109..63da4af 100644 --- a/examples/window_size.rs +++ b/examples/window_rows.rs @@ -9,7 +9,7 @@ fn main() -> std::io::Result<()> { // to see the automatic window-size adjustment. let selected = cliclack::select("Select an item") .items(&items) - .set_size(10) // Specify a custom window-size + .set_max_rows(10) // Specify a custom window-size .filter_mode() // Try filtering on "1" .interact()?; diff --git a/src/multiselect.rs b/src/multiselect.rs index a1c871d..835facf 100644 --- a/src/multiselect.rs +++ b/src/multiselect.rs @@ -92,15 +92,16 @@ where /// /// The filter mode allows to filter the items by typing. pub fn filter_mode(mut self) -> Self { - let term_size = self.term.get_size(); - self.term.set_size(term_size.checked_sub(1).unwrap_or(term_size)); + 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 items that are able to be displayed at once - pub fn set_size(mut self, size: usize) -> Self { - self.term.set_size(size); + /// 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 } @@ -150,8 +151,8 @@ impl PromptInteraction> for MultiSelect { self.cursor += 1; } - if self.cursor >= self.term.get_pos() + self.term.get_size() { - self.term.set_pos(self.cursor - self.term.get_size() + 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(' ') => { @@ -209,7 +210,7 @@ impl PromptInteraction> for MultiSelect { .map(|i| i.borrow()) .enumerate() .skip(self.term.get_pos()) - .take(self.term.get_size()) + .take(self.term.get_max_rows()) { items_render.push_str(&theme.format_multiselect_item( &state.into(), diff --git a/src/prompt/term.rs b/src/prompt/term.rs index 9ad6680..6f7e94a 100644 --- a/src/prompt/term.rs +++ b/src/prompt/term.rs @@ -1,30 +1,32 @@ pub(crate) struct TermSize { - window_size: usize, + window_max_rows: usize, window_pos: usize, } impl Default for TermSize { fn default() -> Self { - let mut window_size = usize::MAX; + let mut window_max_rows = usize::MAX; if let Some(termsize) = termsize::get() { - window_size = (termsize.rows as usize).checked_sub(3).unwrap_or(termsize.rows as usize); + window_max_rows = (termsize.rows as usize) + .checked_sub(3) + .unwrap_or(termsize.rows as usize); } Self { - window_size, + window_max_rows, window_pos: 0, } } } impl TermSize { - pub fn get_size(&self) -> usize { - self.window_size + pub fn get_max_rows(&self) -> usize { + self.window_max_rows } - pub fn set_size(&mut self, size: usize) { - self.window_size = size; + pub fn set_max_rows(&mut self, rows: usize) { + self.window_max_rows = rows; } pub fn get_pos(&self) -> usize { diff --git a/src/select.rs b/src/select.rs index 1d53af1..ff0ba6b 100644 --- a/src/select.rs +++ b/src/select.rs @@ -81,15 +81,16 @@ where /// /// The filter mode allows to filter the items by typing. pub fn filter_mode(mut self) -> Self { - let term_size = self.term.get_size(); - self.term.set_size(term_size.checked_sub(1).unwrap_or(term_size as usize)); + 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 items that are able to be displayed at once - pub fn set_size(mut self, size: usize) -> Self { - self.term.set_size(size); + /// 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 } @@ -139,8 +140,8 @@ impl PromptInteraction for Select { self.cursor += 1; } - if self.cursor >= self.term.get_pos() + self.term.get_size() { - self.term.set_pos(self.cursor - self.term.get_size() + 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 => { @@ -173,7 +174,7 @@ impl PromptInteraction for Select { .iter() .enumerate() .skip(self.term.get_pos()) - .take(self.term.get_size()) + .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) From 7611784262003a1eafa70b7f1ebb4e7eff2c5c94 Mon Sep 17 00:00:00 2001 From: Bjorn Beishline <75190918+BjornTheProgrammer@users.noreply.github.com> Date: Wed, 8 Jan 2025 12:25:57 -0700 Subject: [PATCH 5/5] Implement temp workaround for ssh terms --- Cargo.toml | 3 ++ src/lib.rs | 2 +- src/prompt/term.rs | 88 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9a76930..7ebf308 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,9 @@ 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" rand = "0.8.5" 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/prompt/term.rs b/src/prompt/term.rs index 6f7e94a..6de35c5 100644 --- a/src/prompt/term.rs +++ b/src/prompt/term.rs @@ -7,7 +7,7 @@ impl Default for TermSize { fn default() -> Self { let mut window_max_rows = usize::MAX; - if let Some(termsize) = termsize::get() { + if let Some(termsize) = get_term_size() { window_max_rows = (termsize.rows as usize) .checked_sub(3) .unwrap_or(termsize.rows as usize); @@ -37,3 +37,89 @@ impl TermSize { 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 + } +}