1#[derive(Debug, Clone, Default)]
6pub struct ListState {
7 pub items: Vec<String>,
9 pub selected: usize,
11 pub filter: String,
13 view_indices: Vec<usize>,
14 item_search_cache: Vec<String>,
17}
18
19impl ListState {
20 pub fn new(items: Vec<impl Into<String>>) -> Self {
22 let items: Vec<String> = items.into_iter().map(Into::into).collect();
23 let item_search_cache: Vec<String> =
24 items.iter().map(|s| s.to_lowercase()).collect();
25 let len = items.len();
26 Self {
27 items,
28 selected: 0,
29 filter: String::new(),
30 view_indices: (0..len).collect(),
31 item_search_cache,
32 }
33 }
34
35 pub fn set_items(&mut self, items: Vec<impl Into<String>>) {
40 self.items = items.into_iter().map(Into::into).collect();
41 self.item_search_cache = self.items.iter().map(|s| s.to_lowercase()).collect();
42 self.selected = self.selected.min(self.items.len().saturating_sub(1));
43 self.rebuild_view();
44 }
45
46 pub fn set_filter(&mut self, filter: impl Into<String>) {
50 self.filter = filter.into();
51 self.rebuild_view();
52 }
53
54 pub fn visible_indices(&self) -> &[usize] {
56 &self.view_indices
57 }
58
59 pub fn selected_item(&self) -> Option<&str> {
61 let data_idx = *self.view_indices.get(self.selected)?;
62 self.items.get(data_idx).map(String::as_str)
63 }
64
65 fn rebuild_view(&mut self) {
66 let tokens: Vec<String> = self
67 .filter
68 .split_whitespace()
69 .map(|t| t.to_lowercase())
70 .collect();
71 self.view_indices = if tokens.is_empty() {
72 (0..self.items.len()).collect()
73 } else {
74 (0..self.items.len())
75 .filter(|&i| {
76 let cached = match self.item_search_cache.get(i) {
77 Some(s) => s.as_str(),
78 None => return false,
79 };
80 tokens.iter().all(|token| cached.contains(token.as_str()))
81 })
82 .collect()
83 };
84 if !self.view_indices.is_empty() && self.selected >= self.view_indices.len() {
85 self.selected = self.view_indices.len() - 1;
86 }
87 }
88}
89
90#[derive(Debug, Clone)]
94pub struct FilePickerState {
95 pub current_dir: PathBuf,
97 pub entries: Vec<FileEntry>,
99 pub selected: usize,
101 pub selected_file: Option<PathBuf>,
103 pub show_hidden: bool,
105 pub extensions: Vec<String>,
107 pub dirty: bool,
109}
110
111#[derive(Debug, Clone, Default)]
113pub struct FileEntry {
114 pub name: String,
116 pub path: PathBuf,
118 pub is_dir: bool,
120 pub size: u64,
122}
123
124impl FilePickerState {
125 pub fn new(dir: impl Into<PathBuf>) -> Self {
127 Self {
128 current_dir: dir.into(),
129 entries: Vec::new(),
130 selected: 0,
131 selected_file: None,
132 show_hidden: false,
133 extensions: Vec::new(),
134 dirty: true,
135 }
136 }
137
138 pub fn show_hidden(mut self, show: bool) -> Self {
140 self.show_hidden = show;
141 self.dirty = true;
142 self
143 }
144
145 pub fn extensions(mut self, exts: &[&str]) -> Self {
147 self.extensions = exts
148 .iter()
149 .map(|ext| ext.trim().trim_start_matches('.').to_ascii_lowercase())
150 .filter(|ext| !ext.is_empty())
151 .collect();
152 self.dirty = true;
153 self
154 }
155
156 pub fn selected(&self) -> Option<&PathBuf> {
158 self.selected_file.as_ref()
159 }
160
161 pub fn refresh(&mut self) {
163 let mut entries = Vec::new();
164
165 if let Ok(read_dir) = fs::read_dir(&self.current_dir) {
166 for dir_entry in read_dir.flatten() {
167 let name = dir_entry.file_name().to_string_lossy().to_string();
168 if !self.show_hidden && name.starts_with('.') {
169 continue;
170 }
171
172 let Ok(file_type) = dir_entry.file_type() else {
173 continue;
174 };
175 if file_type.is_symlink() {
176 continue;
177 }
178
179 let path = dir_entry.path();
180 let is_dir = file_type.is_dir();
181
182 if !is_dir && !self.extensions.is_empty() {
183 let ext = path
184 .extension()
185 .and_then(|e| e.to_str())
186 .map(|e| e.to_ascii_lowercase());
187 let Some(ext) = ext else {
188 continue;
189 };
190 if !self.extensions.iter().any(|allowed| allowed == &ext) {
191 continue;
192 }
193 }
194
195 let size = if is_dir {
196 0
197 } else {
198 fs::symlink_metadata(&path).map(|m| m.len()).unwrap_or(0)
199 };
200
201 entries.push(FileEntry {
202 name,
203 path,
204 is_dir,
205 size,
206 });
207 }
208 }
209
210 entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
211 (true, false) => std::cmp::Ordering::Less,
212 (false, true) => std::cmp::Ordering::Greater,
213 _ => a
214 .name
215 .to_ascii_lowercase()
216 .cmp(&b.name.to_ascii_lowercase())
217 .then_with(|| a.name.cmp(&b.name)),
218 });
219
220 self.entries = entries;
221 if self.entries.is_empty() {
222 self.selected = 0;
223 } else {
224 self.selected = self.selected.min(self.entries.len().saturating_sub(1));
225 }
226 self.dirty = false;
227 }
228}
229
230impl Default for FilePickerState {
231 fn default() -> Self {
232 Self::new(".")
233 }
234}
235
236#[derive(Debug, Clone, Default)]
241pub struct TabsState {
242 pub labels: Vec<String>,
244 pub selected: usize,
246}
247
248impl TabsState {
249 pub fn new(labels: Vec<impl Into<String>>) -> Self {
251 Self {
252 labels: labels.into_iter().map(Into::into).collect(),
253 selected: 0,
254 }
255 }
256
257 pub fn selected_label(&self) -> Option<&str> {
259 self.labels.get(self.selected).map(String::as_str)
260 }
261}
262
263#[derive(Debug, Clone)]
269pub struct TableState {
270 pub headers: Vec<String>,
272 pub rows: Vec<Vec<String>>,
274 pub selected: usize,
276 column_widths: Vec<u32>,
277 widths_dirty: bool,
278 pub sort_column: Option<usize>,
280 pub sort_ascending: bool,
282 pub filter: String,
284 pub page: usize,
286 pub page_size: usize,
288 pub zebra: bool,
290 view_indices: Vec<usize>,
291 row_search_cache: Vec<String>,
292 filter_tokens: Vec<String>,
293}
294
295impl Default for TableState {
296 fn default() -> Self {
297 Self {
298 headers: Vec::new(),
299 rows: Vec::new(),
300 selected: 0,
301 column_widths: Vec::new(),
302 widths_dirty: true,
303 sort_column: None,
304 sort_ascending: true,
305 filter: String::new(),
306 page: 0,
307 page_size: 0,
308 zebra: false,
309 view_indices: Vec::new(),
310 row_search_cache: Vec::new(),
311 filter_tokens: Vec::new(),
312 }
313 }
314}
315
316impl TableState {
317 pub fn new(headers: Vec<impl Into<String>>, rows: Vec<Vec<impl Into<String>>>) -> Self {
319 let headers: Vec<String> = headers.into_iter().map(Into::into).collect();
320 let rows: Vec<Vec<String>> = rows
321 .into_iter()
322 .map(|r| r.into_iter().map(Into::into).collect())
323 .collect();
324 let mut state = Self {
325 headers,
326 rows,
327 selected: 0,
328 column_widths: Vec::new(),
329 widths_dirty: true,
330 sort_column: None,
331 sort_ascending: true,
332 filter: String::new(),
333 page: 0,
334 page_size: 0,
335 zebra: false,
336 view_indices: Vec::new(),
337 row_search_cache: Vec::new(),
338 filter_tokens: Vec::new(),
339 };
340 state.rebuild_row_search_cache();
341 state.rebuild_view();
342 state.recompute_widths();
343 state
344 }
345
346 pub fn set_rows(&mut self, rows: Vec<Vec<impl Into<String>>>) {
351 self.rows = rows
352 .into_iter()
353 .map(|r| r.into_iter().map(Into::into).collect())
354 .collect();
355 self.rebuild_row_search_cache();
356 self.rebuild_view();
357 }
358
359 pub fn toggle_sort(&mut self, column: usize) {
361 if self.sort_column == Some(column) {
362 self.sort_ascending = !self.sort_ascending;
363 } else {
364 self.sort_column = Some(column);
365 self.sort_ascending = true;
366 }
367 self.rebuild_view();
368 }
369
370 pub fn sort_by(&mut self, column: usize) {
372 if self.sort_column == Some(column) && self.sort_ascending {
373 return;
374 }
375 self.sort_column = Some(column);
376 self.sort_ascending = true;
377 self.rebuild_view();
378 }
379
380 pub fn set_filter(&mut self, filter: impl Into<String>) {
384 let filter = filter.into();
385 if self.filter == filter {
386 return;
387 }
388 self.filter = filter;
389 self.filter_tokens = Self::tokenize_filter(&self.filter);
390 self.page = 0;
391 self.rebuild_view();
392 }
393
394 pub fn clear_sort(&mut self) {
396 if self.sort_column.is_none() && self.sort_ascending {
397 return;
398 }
399 self.sort_column = None;
400 self.sort_ascending = true;
401 self.rebuild_view();
402 }
403
404 pub fn next_page(&mut self) {
406 if self.page_size == 0 {
407 return;
408 }
409 let last_page = self.total_pages().saturating_sub(1);
410 self.page = (self.page + 1).min(last_page);
411 }
412
413 pub fn prev_page(&mut self) {
415 self.page = self.page.saturating_sub(1);
416 }
417
418 pub fn total_pages(&self) -> usize {
420 if self.page_size == 0 {
421 return 1;
422 }
423
424 let len = self.view_indices.len();
425 if len == 0 {
426 1
427 } else {
428 len.div_ceil(self.page_size)
429 }
430 }
431
432 pub fn visible_indices(&self) -> &[usize] {
434 &self.view_indices
435 }
436
437 pub fn selected_row(&self) -> Option<&[String]> {
439 if self.view_indices.is_empty() {
440 return None;
441 }
442 let data_idx = self.view_indices.get(self.selected)?;
443 self.rows.get(*data_idx).map(|r| r.as_slice())
444 }
445
446 fn rebuild_view(&mut self) {
448 let mut indices: Vec<usize> = (0..self.rows.len()).collect();
449
450 if !self.filter_tokens.is_empty() {
451 indices.retain(|&idx| {
452 let searchable = match self.row_search_cache.get(idx) {
453 Some(row) => row,
454 None => return false,
455 };
456 self.filter_tokens
457 .iter()
458 .all(|token| searchable.contains(token.as_str()))
459 });
460 }
461
462 if let Some(column) = self.sort_column {
463 indices.sort_by(|a, b| {
464 let left = self
465 .rows
466 .get(*a)
467 .and_then(|row| row.get(column))
468 .map(String::as_str)
469 .unwrap_or("");
470 let right = self
471 .rows
472 .get(*b)
473 .and_then(|row| row.get(column))
474 .map(String::as_str)
475 .unwrap_or("");
476
477 match (left.parse::<f64>(), right.parse::<f64>()) {
478 (Ok(l), Ok(r)) => l.partial_cmp(&r).unwrap_or(std::cmp::Ordering::Equal),
479 _ => left
480 .chars()
481 .flat_map(char::to_lowercase)
482 .cmp(right.chars().flat_map(char::to_lowercase)),
483 }
484 });
485
486 if !self.sort_ascending {
487 indices.reverse();
488 }
489 }
490
491 self.view_indices = indices;
492
493 if self.page_size > 0 {
494 self.page = self.page.min(self.total_pages().saturating_sub(1));
495 } else {
496 self.page = 0;
497 }
498
499 self.selected = self.selected.min(self.view_indices.len().saturating_sub(1));
500 self.widths_dirty = true;
501 }
502
503 fn rebuild_row_search_cache(&mut self) {
504 self.row_search_cache = self
505 .rows
506 .iter()
507 .map(|row| {
508 let mut searchable = String::new();
509 for (idx, cell) in row.iter().enumerate() {
510 if idx > 0 {
511 searchable.push('\n');
512 }
513 searchable.extend(cell.chars().flat_map(char::to_lowercase));
514 }
515 searchable
516 })
517 .collect();
518 self.filter_tokens = Self::tokenize_filter(&self.filter);
519 self.widths_dirty = true;
520 }
521
522 fn tokenize_filter(filter: &str) -> Vec<String> {
523 filter
524 .split_whitespace()
525 .map(|t| t.to_lowercase())
526 .collect()
527 }
528
529 pub(crate) fn recompute_widths(&mut self) {
530 if !self.widths_dirty {
534 return;
535 }
536 let col_count = self.headers.len();
537 self.column_widths = vec![0u32; col_count];
538 for (i, header) in self.headers.iter().enumerate() {
539 let mut width = UnicodeWidthStr::width(header.as_str()) as u32;
540 if self.sort_column == Some(i) {
541 width += 2;
542 }
543 self.column_widths[i] = width;
544 }
545 for row in &self.rows {
546 for (i, cell) in row.iter().enumerate() {
547 if i < col_count {
548 let w = UnicodeWidthStr::width(cell.as_str()) as u32;
549 self.column_widths[i] = self.column_widths[i].max(w);
550 }
551 }
552 }
553 self.widths_dirty = false;
554 }
555
556 pub(crate) fn column_widths(&self) -> &[u32] {
557 &self.column_widths
558 }
559
560 pub(crate) fn is_dirty(&self) -> bool {
561 self.widths_dirty
562 }
563}
564
565#[derive(Debug, Clone)]
571pub struct ScrollState {
572 pub offset: usize,
574 content_height: u32,
575 viewport_height: u32,
576}
577
578impl ScrollState {
579 pub fn new() -> Self {
581 Self {
582 offset: 0,
583 content_height: 0,
584 viewport_height: 0,
585 }
586 }
587
588 pub fn can_scroll_up(&self) -> bool {
590 self.offset > 0
591 }
592
593 pub fn can_scroll_down(&self) -> bool {
595 (self.offset as u32) + self.viewport_height < self.content_height
596 }
597
598 pub fn content_height(&self) -> u32 {
600 self.content_height
601 }
602
603 pub fn viewport_height(&self) -> u32 {
605 self.viewport_height
606 }
607
608 pub fn progress(&self) -> f32 {
610 let max = self.content_height.saturating_sub(self.viewport_height);
611 if max == 0 {
612 0.0
613 } else {
614 self.offset as f32 / max as f32
615 }
616 }
617
618 pub fn scroll_up(&mut self, amount: usize) {
620 self.offset = self.offset.saturating_sub(amount);
621 }
622
623 pub fn scroll_down(&mut self, amount: usize) {
625 let max_offset = self.content_height.saturating_sub(self.viewport_height) as usize;
626 self.offset = (self.offset + amount).min(max_offset);
627 }
628
629 pub(crate) fn set_bounds(&mut self, content_height: u32, viewport_height: u32) {
630 self.content_height = content_height;
631 self.viewport_height = viewport_height;
632 }
633}
634
635impl Default for ScrollState {
636 fn default() -> Self {
637 Self::new()
638 }
639}
640
641#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
661pub enum GridColumn {
662 Auto,
664 Fixed(u32),
666 Grow(u16),
669 Percent(u8),
671}