#[derive(Debug, Clone, Default)]
pub struct ListState {
pub items: Vec<String>,
pub selected: usize,
pub filter: String,
pub(crate) viewport_offset: usize,
pub(crate) viewport_row_offset: usize,
item_heights: Option<Vec<u32>>,
row_prefix: Vec<u32>,
heights_dirty: bool,
view_indices: Vec<usize>,
item_search_cache: Vec<String>,
}
impl ListState {
pub fn new(items: Vec<impl Into<String>>) -> Self {
let items: Vec<String> = items.into_iter().map(Into::into).collect();
let item_search_cache: Vec<String> =
items.iter().map(|s| s.to_lowercase()).collect();
let len = items.len();
Self {
items,
selected: 0,
filter: String::new(),
viewport_offset: 0,
viewport_row_offset: 0,
item_heights: None,
row_prefix: Vec::new(),
heights_dirty: true,
view_indices: (0..len).collect(),
item_search_cache,
}
}
pub fn set_items(&mut self, items: Vec<impl Into<String>>) {
self.items = items.into_iter().map(Into::into).collect();
self.item_search_cache = self.items.iter().map(|s| s.to_lowercase()).collect();
self.selected = self.selected.min(self.items.len().saturating_sub(1));
self.heights_dirty = true;
self.rebuild_view();
}
pub fn with_item_heights(mut self, heights: Vec<u32>) -> Self {
self.set_item_heights(heights);
self
}
pub fn set_item_heights(&mut self, heights: Vec<u32>) {
self.item_heights = Some(heights.into_iter().map(|h| h.max(1)).collect());
self.heights_dirty = true;
}
pub fn clear_item_heights(&mut self) {
self.item_heights = None;
self.heights_dirty = true;
}
pub(crate) fn has_item_heights(&self) -> bool {
self.item_heights.is_some()
}
pub(crate) fn item_height(&self, idx: usize) -> u32 {
self.item_heights
.as_ref()
.and_then(|h| h.get(idx).copied())
.unwrap_or(1)
}
pub(crate) fn ensure_row_prefix(&mut self) {
if !self.heights_dirty && self.row_prefix.len() == self.items.len() + 1 {
return;
}
let n = self.items.len();
self.row_prefix.clear();
self.row_prefix.reserve(n + 1);
let mut acc = 0u32;
self.row_prefix.push(0);
for i in 0..n {
acc = acc.saturating_add(self.item_height(i));
self.row_prefix.push(acc);
}
self.heights_dirty = false;
}
pub(crate) fn row_prefix(&self) -> &[u32] {
&self.row_prefix
}
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)
}
pub fn move_item(&mut self, from: usize, to: usize) -> bool {
let len = self.items.len();
if from >= len || to >= len || from == to {
return false;
}
let selected_data = self.view_indices.get(self.selected).copied();
let item = self.items.remove(from);
self.items.insert(to, item);
if from < self.item_search_cache.len() {
let cached = self.item_search_cache.remove(from);
self.item_search_cache.insert(to.min(self.item_search_cache.len()), cached);
}
if let Some(heights) = self.item_heights.as_mut() {
if from < heights.len() {
let h = heights.remove(from);
heights.insert(to.min(heights.len()), h);
}
}
self.heights_dirty = true;
self.rebuild_view();
if let Some(data_idx) = selected_data {
let new_data_idx = if data_idx == from {
to
} else if from < to && data_idx > from && data_idx <= to {
data_idx - 1
} else if to < from && data_idx >= to && data_idx < from {
data_idx + 1
} else {
data_idx
};
if let Some(view_pos) = self.view_indices.iter().position(|&i| i == new_data_idx) {
self.selected = view_pos;
}
}
true
}
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| {
let cached = match self.item_search_cache.get(i) {
Some(s) => s.as_str(),
None => return false,
};
tokens.iter().all(|token| cached.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, Default)]
#[must_use = "ListResponse contains interaction state — check .reordered, .changed, or .hovered"]
pub struct ListResponse {
pub response: Response,
pub reordered: Option<(usize, usize)>,
}
impl std::ops::Deref for ListResponse {
type Target = Response;
fn deref(&self) -> &Response {
&self.response
}
}
#[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_file(&self) -> Option<&PathBuf> {
self.selected_file.as_ref()
}
#[deprecated(since = "0.20.0", note = "use selected_file() — disambiguates from the `selected: usize` field index")]
pub fn selected(&self) -> Option<&PathBuf> {
self.selected_file()
}
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, Copy, PartialEq, Eq, Hash)]
pub enum TableColumn {
Auto,
Fixed(u32),
Min(u32),
Max(u32),
Percent(u8),
}
#[derive(Debug, Clone)]
pub struct TableState {
pub headers: Vec<String>,
pub rows: Vec<Vec<String>>,
pub selected: usize,
pub multi_selected: HashSet<usize>,
pub(crate) selection_anchor: Option<usize>,
column_specs: Vec<TableColumn>,
column_widths: Vec<u32>,
content_widths: Vec<u32>,
widths_dirty: bool,
resolved_width: u32,
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,
multi_selected: HashSet::new(),
selection_anchor: None,
column_specs: Vec::new(),
column_widths: Vec::new(),
content_widths: Vec::new(),
widths_dirty: true,
resolved_width: 0,
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,
multi_selected: HashSet::new(),
selection_anchor: None,
column_specs: Vec::new(),
column_widths: Vec::new(),
content_widths: Vec::new(),
widths_dirty: true,
resolved_width: 0,
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())
}
pub fn column_widths_spec(&mut self, specs: &[TableColumn]) {
self.column_specs = specs.to_vec();
self.widths_dirty = true;
}
pub fn selected_rows(&self) -> Vec<&[String]> {
let mut indices: Vec<usize> = self.multi_selected.iter().copied().collect();
indices.sort_unstable();
indices
.iter()
.filter_map(|&view_idx| self.view_indices.get(view_idx))
.filter_map(|&data_idx| self.rows.get(data_idx).map(|r| r.as_slice()))
.collect()
}
pub fn is_row_selected(&self, view_idx: usize) -> bool {
self.multi_selected.contains(&view_idx)
}
pub fn clear_selection(&mut self) {
self.multi_selected.clear();
self.selection_anchor = None;
}
pub(crate) fn toggle_row(&mut self, view_idx: usize) {
if self.multi_selected.contains(&view_idx) {
self.multi_selected.remove(&view_idx);
} else {
self.multi_selected.insert(view_idx);
}
self.selection_anchor = Some(view_idx);
}
pub(crate) fn select_single(&mut self, view_idx: usize) {
self.multi_selected.clear();
self.multi_selected.insert(view_idx);
self.selection_anchor = Some(view_idx);
}
pub(crate) fn select_range(&mut self, from: usize, to: usize) {
let (lo, hi) = if from <= to { (from, to) } else { (to, from) };
self.multi_selected.clear();
for idx in lo..=hi {
self.multi_selected.insert(idx);
}
self.selection_anchor = Some(from);
}
fn prune_selection(&mut self) {
let view_len = self.view_indices.len();
self.multi_selected.retain(|&idx| idx < view_len);
if let Some(anchor) = self.selection_anchor {
if anchor >= view_len {
self.selection_anchor = None;
}
}
}
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.prune_selection();
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) {
if !self.widths_dirty {
return;
}
let col_count = self.headers.len();
self.content_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.content_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.content_widths[i] = self.content_widths[i].max(w);
}
}
}
self.column_widths = self.content_widths.clone();
self.widths_dirty = false;
}
pub(crate) fn resolve_column_widths(&mut self, available: u32) {
if self.column_specs.is_empty() {
return;
}
if self.resolved_width != available {
self.column_widths = self.content_widths.clone();
self.resolved_width = available;
}
let col_count = self.column_widths.len();
for i in 0..col_count {
let content = self.content_widths.get(i).copied().unwrap_or(0);
let spec = self.column_specs.get(i).copied().unwrap_or(TableColumn::Auto);
let resolved = match spec {
TableColumn::Auto => content,
TableColumn::Fixed(n) => n,
TableColumn::Min(n) => content.max(n),
TableColumn::Max(n) => content.min(n),
TableColumn::Percent(pct) => {
let pct = pct.clamp(1, 100) as u32;
(available.saturating_mul(pct)) / 100
}
};
self.column_widths[i] = resolved;
}
}
pub(crate) fn column_widths(&self) -> &[u32] {
&self.column_widths
}
pub(crate) fn is_dirty(&self) -> bool {
self.widths_dirty
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PaginatorStyle {
#[default]
Dots,
Arabic,
}
#[derive(Debug, Clone)]
pub struct PaginatorState {
pub total_items: usize,
pub per_page: usize,
pub page: usize,
pub style: PaginatorStyle,
}
impl PaginatorState {
pub fn new(total_items: usize, per_page: usize) -> Self {
Self {
total_items,
per_page: per_page.max(1),
page: 0,
style: PaginatorStyle::default(),
}
}
pub fn total_pages(&self) -> usize {
self.total_items.div_ceil(self.per_page.max(1)).max(1)
}
pub fn page_bounds(&self) -> (usize, usize) {
let start = self
.page
.saturating_mul(self.per_page)
.min(self.total_items);
let end = start.saturating_add(self.per_page).min(self.total_items);
(start, end)
}
pub fn next_page(&mut self) {
self.page = (self.page + 1).min(self.total_pages().saturating_sub(1));
}
pub fn prev_page(&mut self) {
self.page = self.page.saturating_sub(1);
}
pub fn set_page(&mut self, page: usize) {
self.page = page.min(self.total_pages().saturating_sub(1));
}
pub fn set_total_items(&mut self, total: usize) {
self.total_items = total;
self.page = self.page.min(self.total_pages().saturating_sub(1));
}
pub fn set_per_page(&mut self, per_page: usize) {
self.per_page = per_page.max(1);
self.page = self.page.min(self.total_pages().saturating_sub(1));
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HighlightRange {
pub start_line: usize,
pub line_count: usize,
}
impl HighlightRange {
pub fn line(line: usize) -> Self {
Self {
start_line: line,
line_count: 1,
}
}
pub fn span(start_line: usize, line_count: usize) -> Self {
Self {
start_line,
line_count: line_count.max(1),
}
}
pub fn contains(&self, line: usize) -> bool {
line >= self.start_line && line < self.start_line + self.line_count
}
}
#[derive(Debug, Clone)]
pub struct ScrollState {
pub offset: usize,
pub offset_x: usize,
pub dragging: bool,
content_height: u32,
viewport_height: u32,
content_width: u32,
viewport_width: u32,
highlights: Vec<HighlightRange>,
current_highlight: Option<usize>,
}
impl ScrollState {
pub fn new() -> Self {
Self {
offset: 0,
offset_x: 0,
dragging: false,
content_height: 0,
viewport_height: 0,
content_width: 0,
viewport_width: 0,
highlights: Vec::new(),
current_highlight: None,
}
}
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_ratio(&self) -> f64 {
let max = self.content_height.saturating_sub(self.viewport_height);
if max == 0 {
0.0
} else {
self.offset as f64 / max as f64
}
}
#[deprecated(
since = "0.21.0",
note = "use progress_ratio() — f64 matches the rest of the v0.20+ ratio surface (gauge/progress_bar take f64; drop any `as f64` cast)"
)]
pub fn progress(&self) -> f32 {
self.progress_ratio() 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 fn set_offset(&mut self, offset: usize) {
let max_offset = self.content_height.saturating_sub(self.viewport_height) as usize;
self.offset = offset.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;
}
pub(crate) fn set_bounds_x(&mut self, content_width: u32, viewport_width: u32) {
self.content_width = content_width;
self.viewport_width = viewport_width;
}
pub fn can_scroll_left(&self) -> bool {
self.offset_x > 0
}
pub fn can_scroll_right(&self) -> bool {
(self.offset_x as u32) + self.viewport_width < self.content_width
}
pub fn content_width(&self) -> u32 {
self.content_width
}
pub fn viewport_width(&self) -> u32 {
self.viewport_width
}
pub fn progress_x(&self) -> f64 {
let max = self.content_width.saturating_sub(self.viewport_width);
if max == 0 {
0.0
} else {
self.offset_x as f64 / max as f64
}
}
pub fn scroll_left(&mut self, amount: usize) {
self.offset_x = self.offset_x.saturating_sub(amount);
}
pub fn scroll_right(&mut self, amount: usize) {
let max_offset = self.content_width.saturating_sub(self.viewport_width) as usize;
self.offset_x = (self.offset_x + amount).min(max_offset);
}
pub fn set_highlights(&mut self, ranges: &[HighlightRange]) {
self.highlights.clear();
self.highlights.extend_from_slice(ranges);
self.current_highlight = if self.highlights.is_empty() {
None
} else {
Some(0)
};
}
pub fn highlights(&self) -> &[HighlightRange] {
&self.highlights
}
pub fn current_highlight(&self) -> Option<usize> {
self.current_highlight
}
pub fn clear_highlights(&mut self) {
self.highlights.clear();
self.current_highlight = None;
}
pub fn highlight_next(&mut self) {
if self.highlights.is_empty() {
return;
}
let next = match self.current_highlight {
Some(i) => (i + 1) % self.highlights.len(),
None => 0,
};
self.current_highlight = Some(next);
self.scroll_to_current_highlight();
}
pub fn highlight_previous(&mut self) {
if self.highlights.is_empty() {
return;
}
let next = match self.current_highlight {
Some(i) => {
if i == 0 {
self.highlights.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.current_highlight = Some(next);
self.scroll_to_current_highlight();
}
pub fn scroll_to_current_highlight(&mut self) {
let Some(idx) = self.current_highlight else {
return;
};
let Some(range) = self.highlights.get(idx).copied() else {
return;
};
let target = range.start_line;
let viewport = self.viewport_height as usize;
let content = self.content_height as usize;
let max_offset = content.saturating_sub(viewport);
if target < self.offset {
self.offset = target.saturating_sub(1).min(max_offset);
} else if viewport > 0 && target >= self.offset + viewport {
let desired = target + 2;
let new_offset = desired.saturating_sub(viewport);
self.offset = new_offset.min(max_offset);
} else if self.offset > max_offset {
self.offset = max_offset;
}
}
}
impl Default for ScrollState {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SplitPaneState {
pub ratio: f64,
pub dragging: bool,
pub min_ratio: f64,
}
pub(crate) const DEFAULT_SPLIT_MIN_RATIO: f64 = 0.10;
impl SplitPaneState {
pub fn new(ratio: f64) -> Self {
let min_ratio = DEFAULT_SPLIT_MIN_RATIO;
let clamped = ratio.clamp(min_ratio, 1.0 - min_ratio);
Self {
ratio: clamped,
dragging: false,
min_ratio,
}
}
pub fn with_min_ratio(mut self, min: f64) -> Self {
self.min_ratio = min.clamp(0.0, 0.49);
self.ratio = self.ratio.clamp(self.min_ratio, 1.0 - self.min_ratio);
self
}
pub fn set_ratio(&mut self, ratio: f64) {
self.ratio = ratio.clamp(self.min_ratio, 1.0 - self.min_ratio);
}
}
impl Default for SplitPaneState {
fn default() -> Self {
Self::new(0.5)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GridColumn {
Auto,
Fixed(u32),
Grow(u16),
Percent(u8),
}
#[cfg(test)]
mod table_v021_width_tests {
use super::TableColumn;
use super::TableState;
fn resolved(specs: &[TableColumn], content: &str, available: u32) -> u32 {
let mut state = TableState::new(vec!["H"], vec![vec![content]]);
state.column_widths_spec(specs);
state.recompute_widths();
state.resolve_column_widths(available);
state.column_widths()[0]
}
#[test]
fn fixed_overrides_content() {
assert_eq!(resolved(&[TableColumn::Fixed(5)], "averylongcell", 80), 5);
assert_eq!(resolved(&[TableColumn::Fixed(20)], "x", 80), 20);
}
#[test]
fn min_floors_content() {
assert_eq!(resolved(&[TableColumn::Min(10)], "x", 80), 10);
assert_eq!(resolved(&[TableColumn::Min(2)], "abcdef", 80), 6);
}
#[test]
fn max_caps_content() {
assert_eq!(resolved(&[TableColumn::Max(4)], "abcdefghij", 80), 4);
assert_eq!(resolved(&[TableColumn::Max(10)], "abc", 80), 3);
}
#[test]
fn percent_of_available() {
let mut state = TableState::new(vec!["A", "B"], vec![vec!["x", "y"]]);
state.column_widths_spec(&[TableColumn::Percent(50), TableColumn::Percent(50)]);
state.recompute_widths();
state.resolve_column_widths(40);
assert_eq!(state.column_widths(), &[20, 20]);
}
#[test]
fn auto_equals_content_width() {
assert_eq!(resolved(&[], "hello", 80), 5);
assert_eq!(resolved(&[TableColumn::Auto], "hello", 80), 5);
}
#[test]
fn select_range_fills_inclusive() {
let mut state = TableState::new(vec!["N"], vec![vec!["a"]; 5]);
state.select_range(1, 3);
let mut got: Vec<usize> = state.multi_selected.iter().copied().collect();
got.sort_unstable();
assert_eq!(got, vec![1, 2, 3]);
state.select_range(3, 1);
let mut got: Vec<usize> = state.multi_selected.iter().copied().collect();
got.sort_unstable();
assert_eq!(got, vec![1, 2, 3]);
}
#[test]
fn toggle_row_inserts_then_removes() {
let mut state = TableState::new(vec!["N"], vec![vec!["a"]; 3]);
state.toggle_row(1);
assert!(state.is_row_selected(1));
state.toggle_row(1);
assert!(!state.is_row_selected(1));
}
proptest::proptest! {
#[test]
fn fixed_min_max_invariants(
content_len in 0usize..40,
spec_kind in 0u8..4,
n in 0u32..30,
available in 1u32..200,
) {
let content: String = "x".repeat(content_len);
let spec = match spec_kind {
0 => TableColumn::Fixed(n),
1 => TableColumn::Min(n),
2 => TableColumn::Max(n),
_ => TableColumn::Auto,
};
let w = resolved(&[spec], &content, available);
match spec {
TableColumn::Fixed(n) => proptest::prop_assert_eq!(w, n),
TableColumn::Min(n) => proptest::prop_assert!(w >= n),
TableColumn::Max(n) => proptest::prop_assert!(w <= n),
_ => {}
}
}
#[test]
fn percent_columns_never_exceed_available(
pcts in proptest::collection::vec(1u8..=100, 1..6),
available in 1u32..200,
) {
let cols = pcts.len();
let headers: Vec<String> = (0..cols).map(|i| format!("H{i}")).collect();
let row: Vec<String> = (0..cols).map(|_| "v".to_string()).collect();
let mut state = TableState::new(headers, vec![row]);
let specs: Vec<TableColumn> = pcts.iter().map(|&p| TableColumn::Percent(p)).collect();
state.column_widths_spec(&specs);
state.recompute_widths();
state.resolve_column_widths(available);
for (&w, &p) in state.column_widths().iter().zip(pcts.iter()) {
let expected = (available.saturating_mul(p as u32)) / 100;
proptest::prop_assert_eq!(w, expected);
proptest::prop_assert!(w <= available);
}
}
}
}
#[cfg(test)]
mod list_state_height_tests {
use super::ListState;
#[test]
fn row_prefix_is_cumulative_sum() {
let mut state = ListState::new(vec!["a", "b", "c", "d"]);
state.set_item_heights(vec![2, 1, 3, 1]);
state.ensure_row_prefix();
assert_eq!(state.row_prefix(), &[0, 2, 3, 6, 7]);
assert_eq!(state.item_height(0), 2);
assert_eq!(state.item_height(2), 3);
}
#[test]
fn heights_below_one_are_clamped() {
let mut state = ListState::new(vec!["a", "b", "c"]);
state.set_item_heights(vec![0, 0, 0]);
state.ensure_row_prefix();
assert_eq!(state.row_prefix(), &[0, 1, 2, 3]);
assert_eq!(state.item_height(0), 1);
}
#[test]
fn dirty_gate_skips_rebuild_when_unchanged() {
let mut state = ListState::new(vec!["a", "b"]);
state.set_item_heights(vec![3, 2]);
state.ensure_row_prefix();
assert_eq!(state.row_prefix(), &[0, 3, 5]);
assert!(!state.heights_dirty);
state.ensure_row_prefix();
assert_eq!(state.row_prefix(), &[0, 3, 5]);
}
#[test]
fn no_heights_falls_back_to_uniform() {
let mut state = ListState::new(vec!["a", "b", "c"]);
assert!(!state.has_item_heights());
state.ensure_row_prefix();
assert_eq!(state.row_prefix(), &[0, 1, 2, 3]);
assert_eq!(state.item_height(0), 1);
}
#[test]
fn clear_reverts_to_uniform() {
let mut state = ListState::new(vec!["a", "b"]).with_item_heights(vec![4, 2]);
state.ensure_row_prefix();
assert_eq!(state.row_prefix(), &[0, 4, 6]);
state.clear_item_heights();
assert!(!state.has_item_heights());
state.ensure_row_prefix();
assert_eq!(state.row_prefix(), &[0, 1, 2]);
}
#[test]
fn set_items_marks_dirty_and_resizes_prefix() {
let mut state = ListState::new(vec!["a", "b", "c"]).with_item_heights(vec![2, 2, 2]);
state.ensure_row_prefix();
assert_eq!(state.row_prefix(), &[0, 2, 4, 6]);
state.set_items(vec!["x", "y"]);
assert!(state.heights_dirty);
state.ensure_row_prefix();
assert_eq!(state.row_prefix(), &[0, 2, 4]);
}
}
#[cfg(test)]
mod scroll_state_progress_tests {
use super::ScrollState;
fn scrolled(content_height: u32, viewport_height: u32, offset: usize) -> ScrollState {
let mut state = ScrollState::new();
state.set_bounds(content_height, viewport_height);
state.offset = offset;
state
}
#[test]
fn progress_ratio_returns_f64_in_unit_range() {
let top = scrolled(100, 20, 0);
let ratio: f64 = top.progress_ratio();
assert_eq!(ratio, 0.0);
let mid = scrolled(100, 20, 40);
assert_eq!(mid.progress_ratio(), 0.5);
let bottom = scrolled(100, 20, 80);
assert_eq!(bottom.progress_ratio(), 1.0);
}
#[test]
fn progress_ratio_is_zero_when_content_fits_viewport() {
let fits = scrolled(20, 20, 0);
assert_eq!(fits.progress_ratio(), 0.0);
let smaller = scrolled(10, 20, 5);
assert_eq!(smaller.progress_ratio(), 0.0);
}
#[test]
fn progress_ratio_preserves_f64_precision() {
let third = scrolled(40, 10, 10); let ratio = third.progress_ratio();
assert!((ratio - 1.0 / 3.0).abs() < 1e-12);
}
#[test]
#[allow(deprecated)]
fn deprecated_progress_delegates_to_progress_ratio() {
let state = scrolled(100, 20, 40);
let expected = state.progress_ratio() as f32;
assert_eq!(state.progress(), expected);
assert!((state.progress() - 0.5).abs() < f32::EPSILON);
}
}
#[cfg(test)]
mod list_state_reorder_tests {
use super::ListState;
#[test]
fn move_item_forward_reorders_and_keeps_selection() {
let mut state = ListState::new(vec!["a", "b", "c", "d"]);
state.selected = 0; assert!(state.move_item(0, 2));
assert_eq!(state.items, vec!["b", "c", "a", "d"]);
assert_eq!(state.selected_item(), Some("a"));
assert_eq!(state.selected, 2);
}
#[test]
fn move_item_backward_reorders_and_keeps_selection() {
let mut state = ListState::new(vec!["a", "b", "c", "d"]);
state.selected = 3; assert!(state.move_item(3, 1));
assert_eq!(state.items, vec!["a", "d", "b", "c"]);
assert_eq!(state.selected_item(), Some("d"));
assert_eq!(state.selected, 1);
}
#[test]
fn move_item_keeps_search_cache_aligned() {
let mut state = ListState::new(vec!["Apple", "Banana", "Cherry"]);
assert!(state.move_item(0, 2));
state.set_filter("apple");
assert_eq!(state.visible_indices().len(), 1);
assert_eq!(state.selected_item(), Some("Apple"));
}
#[test]
fn move_item_keeps_per_item_heights_aligned() {
let mut state = ListState::new(vec!["a", "b", "c"]).with_item_heights(vec![1, 2, 3]);
assert!(state.move_item(0, 2));
state.ensure_row_prefix();
assert_eq!(state.item_height(0), 2);
assert_eq!(state.item_height(1), 3);
assert_eq!(state.item_height(2), 1);
}
#[test]
fn move_item_noop_when_from_equals_to() {
let mut state = ListState::new(vec!["a", "b", "c"]);
state.selected = 1;
assert!(!state.move_item(1, 1));
assert_eq!(state.items, vec!["a", "b", "c"]);
assert_eq!(state.selected, 1);
}
#[test]
fn move_item_out_of_bounds_is_rejected() {
let mut state = ListState::new(vec!["a", "b", "c"]);
assert!(!state.move_item(0, 9));
assert!(!state.move_item(9, 0));
assert_eq!(state.items, vec!["a", "b", "c"]);
}
#[test]
fn move_item_empty_list_is_rejected() {
let mut state = ListState::new(Vec::<String>::new());
assert!(!state.move_item(0, 0));
assert!(state.items.is_empty());
}
#[test]
fn move_item_leaves_unrelated_selection_in_place() {
let mut state = ListState::new(vec!["a", "b", "c", "d"]);
state.selected = 3; assert!(state.move_item(0, 1)); assert_eq!(state.items, vec!["b", "a", "c", "d"]);
assert_eq!(state.selected_item(), Some("d"));
assert_eq!(state.selected, 3);
}
}