#[derive(Debug, Clone, Default)]
pub struct ListState {
pub items: Vec<String>,
pub selected: usize,
pub filter: String,
view_indices: Vec<usize>,
}
impl ListState {
pub fn new(items: Vec<impl Into<String>>) -> Self {
let len = items.len();
Self {
items: items.into_iter().map(Into::into).collect(),
selected: 0,
filter: String::new(),
view_indices: (0..len).collect(),
}
}
pub fn set_items(&mut self, items: Vec<impl Into<String>>) {
self.items = items.into_iter().map(Into::into).collect();
self.selected = self.selected.min(self.items.len().saturating_sub(1));
self.rebuild_view();
}
pub fn set_filter(&mut self, filter: impl Into<String>) {
self.filter = filter.into();
self.rebuild_view();
}
pub fn visible_indices(&self) -> &[usize] {
&self.view_indices
}
pub fn selected_item(&self) -> Option<&str> {
let data_idx = *self.view_indices.get(self.selected)?;
self.items.get(data_idx).map(String::as_str)
}
fn rebuild_view(&mut self) {
let tokens: Vec<String> = self
.filter
.split_whitespace()
.map(|t| t.to_lowercase())
.collect();
self.view_indices = if tokens.is_empty() {
(0..self.items.len()).collect()
} else {
(0..self.items.len())
.filter(|&i| {
tokens
.iter()
.all(|token| self.items[i].to_lowercase().contains(token.as_str()))
})
.collect()
};
if !self.view_indices.is_empty() && self.selected >= self.view_indices.len() {
self.selected = self.view_indices.len() - 1;
}
}
}
#[derive(Debug, Clone)]
pub struct FilePickerState {
pub current_dir: PathBuf,
pub entries: Vec<FileEntry>,
pub selected: usize,
pub selected_file: Option<PathBuf>,
pub show_hidden: bool,
pub extensions: Vec<String>,
pub dirty: bool,
}
#[derive(Debug, Clone, Default)]
pub struct FileEntry {
pub name: String,
pub path: PathBuf,
pub is_dir: bool,
pub size: u64,
}
impl FilePickerState {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self {
current_dir: dir.into(),
entries: Vec::new(),
selected: 0,
selected_file: None,
show_hidden: false,
extensions: Vec::new(),
dirty: true,
}
}
pub fn show_hidden(mut self, show: bool) -> Self {
self.show_hidden = show;
self.dirty = true;
self
}
pub fn extensions(mut self, exts: &[&str]) -> Self {
self.extensions = exts
.iter()
.map(|ext| ext.trim().trim_start_matches('.').to_ascii_lowercase())
.filter(|ext| !ext.is_empty())
.collect();
self.dirty = true;
self
}
pub fn selected(&self) -> Option<&PathBuf> {
self.selected_file.as_ref()
}
pub fn refresh(&mut self) {
let mut entries = Vec::new();
if let Ok(read_dir) = fs::read_dir(&self.current_dir) {
for dir_entry in read_dir.flatten() {
let name = dir_entry.file_name().to_string_lossy().to_string();
if !self.show_hidden && name.starts_with('.') {
continue;
}
let Ok(file_type) = dir_entry.file_type() else {
continue;
};
if file_type.is_symlink() {
continue;
}
let path = dir_entry.path();
let is_dir = file_type.is_dir();
if !is_dir && !self.extensions.is_empty() {
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase());
let Some(ext) = ext else {
continue;
};
if !self.extensions.iter().any(|allowed| allowed == &ext) {
continue;
}
}
let size = if is_dir {
0
} else {
fs::symlink_metadata(&path).map(|m| m.len()).unwrap_or(0)
};
entries.push(FileEntry {
name,
path,
is_dir,
size,
});
}
}
entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a
.name
.to_ascii_lowercase()
.cmp(&b.name.to_ascii_lowercase())
.then_with(|| a.name.cmp(&b.name)),
});
self.entries = entries;
if self.entries.is_empty() {
self.selected = 0;
} else {
self.selected = self.selected.min(self.entries.len().saturating_sub(1));
}
self.dirty = false;
}
}
impl Default for FilePickerState {
fn default() -> Self {
Self::new(".")
}
}
#[derive(Debug, Clone, Default)]
pub struct TabsState {
pub labels: Vec<String>,
pub selected: usize,
}
impl TabsState {
pub fn new(labels: Vec<impl Into<String>>) -> Self {
Self {
labels: labels.into_iter().map(Into::into).collect(),
selected: 0,
}
}
pub fn selected_label(&self) -> Option<&str> {
self.labels.get(self.selected).map(String::as_str)
}
}
#[derive(Debug, Clone)]
pub struct TableState {
pub headers: Vec<String>,
pub rows: Vec<Vec<String>>,
pub selected: usize,
column_widths: Vec<u32>,
widths_dirty: bool,
pub sort_column: Option<usize>,
pub sort_ascending: bool,
pub filter: String,
pub page: usize,
pub page_size: usize,
pub zebra: bool,
view_indices: Vec<usize>,
row_search_cache: Vec<String>,
filter_tokens: Vec<String>,
}
impl Default for TableState {
fn default() -> Self {
Self {
headers: Vec::new(),
rows: Vec::new(),
selected: 0,
column_widths: Vec::new(),
widths_dirty: true,
sort_column: None,
sort_ascending: true,
filter: String::new(),
page: 0,
page_size: 0,
zebra: false,
view_indices: Vec::new(),
row_search_cache: Vec::new(),
filter_tokens: Vec::new(),
}
}
}
impl TableState {
pub fn new(headers: Vec<impl Into<String>>, rows: Vec<Vec<impl Into<String>>>) -> Self {
let headers: Vec<String> = headers.into_iter().map(Into::into).collect();
let rows: Vec<Vec<String>> = rows
.into_iter()
.map(|r| r.into_iter().map(Into::into).collect())
.collect();
let mut state = Self {
headers,
rows,
selected: 0,
column_widths: Vec::new(),
widths_dirty: true,
sort_column: None,
sort_ascending: true,
filter: String::new(),
page: 0,
page_size: 0,
zebra: false,
view_indices: Vec::new(),
row_search_cache: Vec::new(),
filter_tokens: Vec::new(),
};
state.rebuild_row_search_cache();
state.rebuild_view();
state.recompute_widths();
state
}
pub fn set_rows(&mut self, rows: Vec<Vec<impl Into<String>>>) {
self.rows = rows
.into_iter()
.map(|r| r.into_iter().map(Into::into).collect())
.collect();
self.rebuild_row_search_cache();
self.rebuild_view();
}
pub fn toggle_sort(&mut self, column: usize) {
if self.sort_column == Some(column) {
self.sort_ascending = !self.sort_ascending;
} else {
self.sort_column = Some(column);
self.sort_ascending = true;
}
self.rebuild_view();
}
pub fn sort_by(&mut self, column: usize) {
if self.sort_column == Some(column) && self.sort_ascending {
return;
}
self.sort_column = Some(column);
self.sort_ascending = true;
self.rebuild_view();
}
pub fn set_filter(&mut self, filter: impl Into<String>) {
let filter = filter.into();
if self.filter == filter {
return;
}
self.filter = filter;
self.filter_tokens = Self::tokenize_filter(&self.filter);
self.page = 0;
self.rebuild_view();
}
pub fn clear_sort(&mut self) {
if self.sort_column.is_none() && self.sort_ascending {
return;
}
self.sort_column = None;
self.sort_ascending = true;
self.rebuild_view();
}
pub fn next_page(&mut self) {
if self.page_size == 0 {
return;
}
let last_page = self.total_pages().saturating_sub(1);
self.page = (self.page + 1).min(last_page);
}
pub fn prev_page(&mut self) {
self.page = self.page.saturating_sub(1);
}
pub fn total_pages(&self) -> usize {
if self.page_size == 0 {
return 1;
}
let len = self.view_indices.len();
if len == 0 {
1
} else {
len.div_ceil(self.page_size)
}
}
pub fn visible_indices(&self) -> &[usize] {
&self.view_indices
}
pub fn selected_row(&self) -> Option<&[String]> {
if self.view_indices.is_empty() {
return None;
}
let data_idx = self.view_indices.get(self.selected)?;
self.rows.get(*data_idx).map(|r| r.as_slice())
}
fn rebuild_view(&mut self) {
let mut indices: Vec<usize> = (0..self.rows.len()).collect();
if !self.filter_tokens.is_empty() {
indices.retain(|&idx| {
let searchable = match self.row_search_cache.get(idx) {
Some(row) => row,
None => return false,
};
self.filter_tokens
.iter()
.all(|token| searchable.contains(token.as_str()))
});
}
if let Some(column) = self.sort_column {
indices.sort_by(|a, b| {
let left = self
.rows
.get(*a)
.and_then(|row| row.get(column))
.map(String::as_str)
.unwrap_or("");
let right = self
.rows
.get(*b)
.and_then(|row| row.get(column))
.map(String::as_str)
.unwrap_or("");
match (left.parse::<f64>(), right.parse::<f64>()) {
(Ok(l), Ok(r)) => l.partial_cmp(&r).unwrap_or(std::cmp::Ordering::Equal),
_ => left
.chars()
.flat_map(char::to_lowercase)
.cmp(right.chars().flat_map(char::to_lowercase)),
}
});
if !self.sort_ascending {
indices.reverse();
}
}
self.view_indices = indices;
if self.page_size > 0 {
self.page = self.page.min(self.total_pages().saturating_sub(1));
} else {
self.page = 0;
}
self.selected = self.selected.min(self.view_indices.len().saturating_sub(1));
self.widths_dirty = true;
}
fn rebuild_row_search_cache(&mut self) {
self.row_search_cache = self
.rows
.iter()
.map(|row| {
let mut searchable = String::new();
for (idx, cell) in row.iter().enumerate() {
if idx > 0 {
searchable.push('\n');
}
searchable.extend(cell.chars().flat_map(char::to_lowercase));
}
searchable
})
.collect();
self.filter_tokens = Self::tokenize_filter(&self.filter);
self.widths_dirty = true;
}
fn tokenize_filter(filter: &str) -> Vec<String> {
filter
.split_whitespace()
.map(|t| t.to_lowercase())
.collect()
}
pub(crate) fn recompute_widths(&mut self) {
let col_count = self.headers.len();
self.column_widths = vec![0u32; col_count];
for (i, header) in self.headers.iter().enumerate() {
let mut width = UnicodeWidthStr::width(header.as_str()) as u32;
if self.sort_column == Some(i) {
width += 2;
}
self.column_widths[i] = width;
}
for row in &self.rows {
for (i, cell) in row.iter().enumerate() {
if i < col_count {
let w = UnicodeWidthStr::width(cell.as_str()) as u32;
self.column_widths[i] = self.column_widths[i].max(w);
}
}
}
self.widths_dirty = false;
}
pub(crate) fn column_widths(&self) -> &[u32] {
&self.column_widths
}
pub(crate) fn is_dirty(&self) -> bool {
self.widths_dirty
}
}
#[derive(Debug, Clone)]
pub struct ScrollState {
pub offset: usize,
content_height: u32,
viewport_height: u32,
}
impl ScrollState {
pub fn new() -> Self {
Self {
offset: 0,
content_height: 0,
viewport_height: 0,
}
}
pub fn can_scroll_up(&self) -> bool {
self.offset > 0
}
pub fn can_scroll_down(&self) -> bool {
(self.offset as u32) + self.viewport_height < self.content_height
}
pub fn content_height(&self) -> u32 {
self.content_height
}
pub fn viewport_height(&self) -> u32 {
self.viewport_height
}
pub fn progress(&self) -> f32 {
let max = self.content_height.saturating_sub(self.viewport_height);
if max == 0 {
0.0
} else {
self.offset as f32 / max as f32
}
}
pub fn scroll_up(&mut self, amount: usize) {
self.offset = self.offset.saturating_sub(amount);
}
pub fn scroll_down(&mut self, amount: usize) {
let max_offset = self.content_height.saturating_sub(self.viewport_height) as usize;
self.offset = (self.offset + amount).min(max_offset);
}
pub(crate) fn set_bounds(&mut self, content_height: u32, viewport_height: u32) {
self.content_height = content_height;
self.viewport_height = viewport_height;
}
}
impl Default for ScrollState {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GridColumn {
Auto,
Fixed(u32),
Grow(u16),
Percent(u8),
}