Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
19 changes: 19 additions & 0 deletions examples/window_rows.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@
//! cargo run --example theme
//! ```

#![forbid(unsafe_code)]
// #![forbid(unsafe_code)]
#![warn(missing_docs, unused_qualifications)]

mod confirm;
Expand Down
28 changes: 27 additions & 1 deletion src/multiselect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::{fmt::Display, rc::Rc};

use console::Key;

use crate::prompt::term::TermSize;
use crate::{
filter::{FilteredView, LabeledItem},
prompt::{
Expand Down Expand Up @@ -35,6 +36,7 @@ pub struct MultiSelect<T> {
initial_values: Option<Vec<T>>,
required: bool,
filter: FilteredView<Checkbox<T>>,
term: TermSize,
}

impl<T> MultiSelect<T>
Expand All @@ -50,6 +52,7 @@ where
initial_values: None,
required: true,
filter: FilteredView::default(),
term: TermSize::default(),
}
}

Expand Down Expand Up @@ -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));
Comment on lines +95 to +97
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This trick is not gonna work if you set filter_mode before setting the "max rows". Usually, these setters should be more or less pure/idempotent.

You should remove it here and check the "filter_mode" somewhere, perhaps, in the on callback incrementing or decrementing "1" as needed.

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<Vec<T>> {
if self.items.is_empty() {
Expand Down Expand Up @@ -129,11 +141,19 @@ impl<T: Clone> PromptInteraction<Vec<T>> for MultiSelect<T> {
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();
Expand Down Expand Up @@ -185,7 +205,13 @@ impl<T: Clone> PromptInteraction<Vec<T>> for MultiSelect<T> {
};

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,
Expand Down
1 change: 1 addition & 0 deletions src/prompt/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pub mod cursor;
pub mod interaction;
pub mod term;
125 changes: 125 additions & 0 deletions src/prompt/term.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
pub(crate) struct TermSize {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The better naming would be Frame. The module is managing a fixed-size "frame" within which content is displayed and scrolled, similar to a window frame.

window_max_rows: usize,
window_pos: usize,
Comment on lines +2 to +3
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's just remove the window_ prefix :)

}

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);
}
Comment on lines +10 to +14
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realized that it just not needed. Try to shrink your terminal to a few rows of height, and then run the cargo run --example filter: the list is gonna be forever of a few rows, even if you expand the terminal height.

So, with max_rows = usize::MAX by default, the lists are gonna be working in an old way, which is good for backward compatibility.


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 {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pos -> offset

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<termsize::Size> {
#[cfg(not(unix))]
{
termsize::get()
}

#[cfg(unix)]
{
_get_unix_termsize()
}
}

/// Gets the current terminal size
#[cfg(unix)]
fn _get_unix_termsize() -> Option<termsize::Size> {
// 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
}
}
22 changes: 22 additions & 0 deletions src/select.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::{fmt::Display, rc::Rc};

use console::Key;

use crate::prompt::term::TermSize;
use crate::{
filter::{FilteredView, LabeledItem},
prompt::{
Expand Down Expand Up @@ -33,6 +34,7 @@ pub struct Select<T> {
cursor: usize,
initial_value: Option<T>,
filter: FilteredView<RadioButton<T>>,
term: TermSize,
}

impl<T> Select<T>
Expand All @@ -47,6 +49,7 @@ where
cursor: 0,
initial_value: None,
filter: FilteredView::default(),
term: TermSize::default(),
}
}

Expand Down Expand Up @@ -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<T> {
if self.items.is_empty() {
Expand Down Expand Up @@ -118,11 +130,19 @@ impl<T: Clone> PromptInteraction<T> for Select<T> {
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());
Expand Down Expand Up @@ -153,6 +173,8 @@ impl<T: Clone> PromptInteraction<T> for Select<T> {
.items()
.iter()
.enumerate()
.skip(self.term.get_pos())
.take(self.term.get_max_rows())
Comment on lines +176 to +177
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After renaming it's gonna be like

.skip(self.frame.get_offset())
.take(self.frame.get_max_rows())

.map(|(i, item)| {
let item = item.borrow();
theme.format_select_item(&state.into(), self.cursor == i, &item.label, &item.hint)
Expand Down