1#[derive(Debug, Clone, Default)]
6pub struct ListState {
7 pub items: Vec<String>,
9 pub selected: usize,
11 pub filter: String,
13 pub(crate) viewport_offset: usize,
17 view_indices: Vec<usize>,
18 item_search_cache: Vec<String>,
21}
22
23impl ListState {
24 pub fn new(items: Vec<impl Into<String>>) -> Self {
26 let items: Vec<String> = items.into_iter().map(Into::into).collect();
27 let item_search_cache: Vec<String> =
28 items.iter().map(|s| s.to_lowercase()).collect();
29 let len = items.len();
30 Self {
31 items,
32 selected: 0,
33 filter: String::new(),
34 viewport_offset: 0,
35 view_indices: (0..len).collect(),
36 item_search_cache,
37 }
38 }
39
40 pub fn set_items(&mut self, items: Vec<impl Into<String>>) {
45 self.items = items.into_iter().map(Into::into).collect();
46 self.item_search_cache = self.items.iter().map(|s| s.to_lowercase()).collect();
47 self.selected = self.selected.min(self.items.len().saturating_sub(1));
48 self.rebuild_view();
49 }
50
51 pub fn set_filter(&mut self, filter: impl Into<String>) {
55 self.filter = filter.into();
56 self.rebuild_view();
57 }
58
59 pub fn visible_indices(&self) -> &[usize] {
61 &self.view_indices
62 }
63
64 pub fn selected_item(&self) -> Option<&str> {
66 let data_idx = *self.view_indices.get(self.selected)?;
67 self.items.get(data_idx).map(String::as_str)
68 }
69
70 fn rebuild_view(&mut self) {
71 let tokens: Vec<String> = self
72 .filter
73 .split_whitespace()
74 .map(|t| t.to_lowercase())
75 .collect();
76 self.view_indices = if tokens.is_empty() {
77 (0..self.items.len()).collect()
78 } else {
79 (0..self.items.len())
80 .filter(|&i| {
81 let cached = match self.item_search_cache.get(i) {
82 Some(s) => s.as_str(),
83 None => return false,
84 };
85 tokens.iter().all(|token| cached.contains(token.as_str()))
86 })
87 .collect()
88 };
89 if !self.view_indices.is_empty() && self.selected >= self.view_indices.len() {
90 self.selected = self.view_indices.len() - 1;
91 }
92 }
93}
94
95#[derive(Debug, Clone)]
99pub struct FilePickerState {
100 pub current_dir: PathBuf,
102 pub entries: Vec<FileEntry>,
104 pub selected: usize,
106 pub selected_file: Option<PathBuf>,
108 pub show_hidden: bool,
110 pub extensions: Vec<String>,
112 pub dirty: bool,
114}
115
116#[derive(Debug, Clone, Default)]
118pub struct FileEntry {
119 pub name: String,
121 pub path: PathBuf,
123 pub is_dir: bool,
125 pub size: u64,
127}
128
129impl FilePickerState {
130 pub fn new(dir: impl Into<PathBuf>) -> Self {
132 Self {
133 current_dir: dir.into(),
134 entries: Vec::new(),
135 selected: 0,
136 selected_file: None,
137 show_hidden: false,
138 extensions: Vec::new(),
139 dirty: true,
140 }
141 }
142
143 pub fn show_hidden(mut self, show: bool) -> Self {
145 self.show_hidden = show;
146 self.dirty = true;
147 self
148 }
149
150 pub fn extensions(mut self, exts: &[&str]) -> Self {
152 self.extensions = exts
153 .iter()
154 .map(|ext| ext.trim().trim_start_matches('.').to_ascii_lowercase())
155 .filter(|ext| !ext.is_empty())
156 .collect();
157 self.dirty = true;
158 self
159 }
160
161 pub fn selected_file(&self) -> Option<&PathBuf> {
182 self.selected_file.as_ref()
183 }
184
185 #[deprecated(since = "0.20.0", note = "use selected_file() — disambiguates from the `selected: usize` field index")]
193 pub fn selected(&self) -> Option<&PathBuf> {
194 self.selected_file()
195 }
196
197 pub fn refresh(&mut self) {
199 let mut entries = Vec::new();
200
201 if let Ok(read_dir) = fs::read_dir(&self.current_dir) {
202 for dir_entry in read_dir.flatten() {
203 let name = dir_entry.file_name().to_string_lossy().to_string();
204 if !self.show_hidden && name.starts_with('.') {
205 continue;
206 }
207
208 let Ok(file_type) = dir_entry.file_type() else {
209 continue;
210 };
211 if file_type.is_symlink() {
212 continue;
213 }
214
215 let path = dir_entry.path();
216 let is_dir = file_type.is_dir();
217
218 if !is_dir && !self.extensions.is_empty() {
219 let ext = path
220 .extension()
221 .and_then(|e| e.to_str())
222 .map(|e| e.to_ascii_lowercase());
223 let Some(ext) = ext else {
224 continue;
225 };
226 if !self.extensions.iter().any(|allowed| allowed == &ext) {
227 continue;
228 }
229 }
230
231 let size = if is_dir {
232 0
233 } else {
234 fs::symlink_metadata(&path).map(|m| m.len()).unwrap_or(0)
235 };
236
237 entries.push(FileEntry {
238 name,
239 path,
240 is_dir,
241 size,
242 });
243 }
244 }
245
246 entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
247 (true, false) => std::cmp::Ordering::Less,
248 (false, true) => std::cmp::Ordering::Greater,
249 _ => a
250 .name
251 .to_ascii_lowercase()
252 .cmp(&b.name.to_ascii_lowercase())
253 .then_with(|| a.name.cmp(&b.name)),
254 });
255
256 self.entries = entries;
257 if self.entries.is_empty() {
258 self.selected = 0;
259 } else {
260 self.selected = self.selected.min(self.entries.len().saturating_sub(1));
261 }
262 self.dirty = false;
263 }
264}
265
266impl Default for FilePickerState {
267 fn default() -> Self {
268 Self::new(".")
269 }
270}
271
272#[derive(Debug, Clone, Default)]
277pub struct TabsState {
278 pub labels: Vec<String>,
280 pub selected: usize,
282}
283
284impl TabsState {
285 pub fn new(labels: Vec<impl Into<String>>) -> Self {
287 Self {
288 labels: labels.into_iter().map(Into::into).collect(),
289 selected: 0,
290 }
291 }
292
293 pub fn selected_label(&self) -> Option<&str> {
295 self.labels.get(self.selected).map(String::as_str)
296 }
297}
298
299#[derive(Debug, Clone)]
305pub struct TableState {
306 pub headers: Vec<String>,
308 pub rows: Vec<Vec<String>>,
310 pub selected: usize,
312 column_widths: Vec<u32>,
313 widths_dirty: bool,
314 pub sort_column: Option<usize>,
316 pub sort_ascending: bool,
318 pub filter: String,
320 pub page: usize,
322 pub page_size: usize,
324 pub zebra: bool,
326 view_indices: Vec<usize>,
327 row_search_cache: Vec<String>,
328 filter_tokens: Vec<String>,
329}
330
331impl Default for TableState {
332 fn default() -> Self {
333 Self {
334 headers: Vec::new(),
335 rows: Vec::new(),
336 selected: 0,
337 column_widths: Vec::new(),
338 widths_dirty: true,
339 sort_column: None,
340 sort_ascending: true,
341 filter: String::new(),
342 page: 0,
343 page_size: 0,
344 zebra: false,
345 view_indices: Vec::new(),
346 row_search_cache: Vec::new(),
347 filter_tokens: Vec::new(),
348 }
349 }
350}
351
352impl TableState {
353 pub fn new(headers: Vec<impl Into<String>>, rows: Vec<Vec<impl Into<String>>>) -> Self {
355 let headers: Vec<String> = headers.into_iter().map(Into::into).collect();
356 let rows: Vec<Vec<String>> = rows
357 .into_iter()
358 .map(|r| r.into_iter().map(Into::into).collect())
359 .collect();
360 let mut state = Self {
361 headers,
362 rows,
363 selected: 0,
364 column_widths: Vec::new(),
365 widths_dirty: true,
366 sort_column: None,
367 sort_ascending: true,
368 filter: String::new(),
369 page: 0,
370 page_size: 0,
371 zebra: false,
372 view_indices: Vec::new(),
373 row_search_cache: Vec::new(),
374 filter_tokens: Vec::new(),
375 };
376 state.rebuild_row_search_cache();
377 state.rebuild_view();
378 state.recompute_widths();
379 state
380 }
381
382 pub fn set_rows(&mut self, rows: Vec<Vec<impl Into<String>>>) {
387 self.rows = rows
388 .into_iter()
389 .map(|r| r.into_iter().map(Into::into).collect())
390 .collect();
391 self.rebuild_row_search_cache();
392 self.rebuild_view();
393 }
394
395 pub fn toggle_sort(&mut self, column: usize) {
397 if self.sort_column == Some(column) {
398 self.sort_ascending = !self.sort_ascending;
399 } else {
400 self.sort_column = Some(column);
401 self.sort_ascending = true;
402 }
403 self.rebuild_view();
404 }
405
406 pub fn sort_by(&mut self, column: usize) {
408 if self.sort_column == Some(column) && self.sort_ascending {
409 return;
410 }
411 self.sort_column = Some(column);
412 self.sort_ascending = true;
413 self.rebuild_view();
414 }
415
416 pub fn set_filter(&mut self, filter: impl Into<String>) {
420 let filter = filter.into();
421 if self.filter == filter {
422 return;
423 }
424 self.filter = filter;
425 self.filter_tokens = Self::tokenize_filter(&self.filter);
426 self.page = 0;
427 self.rebuild_view();
428 }
429
430 pub fn clear_sort(&mut self) {
432 if self.sort_column.is_none() && self.sort_ascending {
433 return;
434 }
435 self.sort_column = None;
436 self.sort_ascending = true;
437 self.rebuild_view();
438 }
439
440 pub fn next_page(&mut self) {
442 if self.page_size == 0 {
443 return;
444 }
445 let last_page = self.total_pages().saturating_sub(1);
446 self.page = (self.page + 1).min(last_page);
447 }
448
449 pub fn prev_page(&mut self) {
451 self.page = self.page.saturating_sub(1);
452 }
453
454 pub fn total_pages(&self) -> usize {
456 if self.page_size == 0 {
457 return 1;
458 }
459
460 let len = self.view_indices.len();
461 if len == 0 {
462 1
463 } else {
464 len.div_ceil(self.page_size)
465 }
466 }
467
468 pub fn visible_indices(&self) -> &[usize] {
470 &self.view_indices
471 }
472
473 pub fn selected_row(&self) -> Option<&[String]> {
475 if self.view_indices.is_empty() {
476 return None;
477 }
478 let data_idx = self.view_indices.get(self.selected)?;
479 self.rows.get(*data_idx).map(|r| r.as_slice())
480 }
481
482 fn rebuild_view(&mut self) {
484 let mut indices: Vec<usize> = (0..self.rows.len()).collect();
485
486 if !self.filter_tokens.is_empty() {
487 indices.retain(|&idx| {
488 let searchable = match self.row_search_cache.get(idx) {
489 Some(row) => row,
490 None => return false,
491 };
492 self.filter_tokens
493 .iter()
494 .all(|token| searchable.contains(token.as_str()))
495 });
496 }
497
498 if let Some(column) = self.sort_column {
499 indices.sort_by(|a, b| {
500 let left = self
501 .rows
502 .get(*a)
503 .and_then(|row| row.get(column))
504 .map(String::as_str)
505 .unwrap_or("");
506 let right = self
507 .rows
508 .get(*b)
509 .and_then(|row| row.get(column))
510 .map(String::as_str)
511 .unwrap_or("");
512
513 match (left.parse::<f64>(), right.parse::<f64>()) {
514 (Ok(l), Ok(r)) => l.partial_cmp(&r).unwrap_or(std::cmp::Ordering::Equal),
515 _ => left
516 .chars()
517 .flat_map(char::to_lowercase)
518 .cmp(right.chars().flat_map(char::to_lowercase)),
519 }
520 });
521
522 if !self.sort_ascending {
523 indices.reverse();
524 }
525 }
526
527 self.view_indices = indices;
528
529 if self.page_size > 0 {
530 self.page = self.page.min(self.total_pages().saturating_sub(1));
531 } else {
532 self.page = 0;
533 }
534
535 self.selected = self.selected.min(self.view_indices.len().saturating_sub(1));
536 self.widths_dirty = true;
537 }
538
539 fn rebuild_row_search_cache(&mut self) {
540 self.row_search_cache = self
541 .rows
542 .iter()
543 .map(|row| {
544 let mut searchable = String::new();
545 for (idx, cell) in row.iter().enumerate() {
546 if idx > 0 {
547 searchable.push('\n');
548 }
549 searchable.extend(cell.chars().flat_map(char::to_lowercase));
550 }
551 searchable
552 })
553 .collect();
554 self.filter_tokens = Self::tokenize_filter(&self.filter);
555 self.widths_dirty = true;
556 }
557
558 fn tokenize_filter(filter: &str) -> Vec<String> {
559 filter
560 .split_whitespace()
561 .map(|t| t.to_lowercase())
562 .collect()
563 }
564
565 pub(crate) fn recompute_widths(&mut self) {
566 if !self.widths_dirty {
570 return;
571 }
572 let col_count = self.headers.len();
573 self.column_widths = vec![0u32; col_count];
574 for (i, header) in self.headers.iter().enumerate() {
575 let mut width = UnicodeWidthStr::width(header.as_str()) as u32;
576 if self.sort_column == Some(i) {
577 width += 2;
578 }
579 self.column_widths[i] = width;
580 }
581 for row in &self.rows {
582 for (i, cell) in row.iter().enumerate() {
583 if i < col_count {
584 let w = UnicodeWidthStr::width(cell.as_str()) as u32;
585 self.column_widths[i] = self.column_widths[i].max(w);
586 }
587 }
588 }
589 self.widths_dirty = false;
590 }
591
592 pub(crate) fn column_widths(&self) -> &[u32] {
593 &self.column_widths
594 }
595
596 pub(crate) fn is_dirty(&self) -> bool {
597 self.widths_dirty
598 }
599}
600
601#[derive(Debug, Clone, Copy, PartialEq, Eq)]
607pub struct HighlightRange {
608 pub start_line: usize,
610 pub line_count: usize,
612}
613
614impl HighlightRange {
615 pub fn line(line: usize) -> Self {
620 Self {
621 start_line: line,
622 line_count: 1,
623 }
624 }
625
626 pub fn span(start_line: usize, line_count: usize) -> Self {
628 Self {
629 start_line,
630 line_count: line_count.max(1),
631 }
632 }
633
634 pub fn contains(&self, line: usize) -> bool {
636 line >= self.start_line && line < self.start_line + self.line_count
637 }
638}
639
640#[derive(Debug, Clone)]
646pub struct ScrollState {
647 pub offset: usize,
649 content_height: u32,
650 viewport_height: u32,
651 highlights: Vec<HighlightRange>,
652 current_highlight: Option<usize>,
653}
654
655impl ScrollState {
656 pub fn new() -> Self {
658 Self {
659 offset: 0,
660 content_height: 0,
661 viewport_height: 0,
662 highlights: Vec::new(),
663 current_highlight: None,
664 }
665 }
666
667 pub fn can_scroll_up(&self) -> bool {
669 self.offset > 0
670 }
671
672 pub fn can_scroll_down(&self) -> bool {
674 (self.offset as u32) + self.viewport_height < self.content_height
675 }
676
677 pub fn content_height(&self) -> u32 {
679 self.content_height
680 }
681
682 pub fn viewport_height(&self) -> u32 {
684 self.viewport_height
685 }
686
687 pub fn progress(&self) -> f32 {
689 let max = self.content_height.saturating_sub(self.viewport_height);
690 if max == 0 {
691 0.0
692 } else {
693 self.offset as f32 / max as f32
694 }
695 }
696
697 pub fn scroll_up(&mut self, amount: usize) {
699 self.offset = self.offset.saturating_sub(amount);
700 }
701
702 pub fn scroll_down(&mut self, amount: usize) {
704 let max_offset = self.content_height.saturating_sub(self.viewport_height) as usize;
705 self.offset = (self.offset + amount).min(max_offset);
706 }
707
708 pub(crate) fn set_bounds(&mut self, content_height: u32, viewport_height: u32) {
709 self.content_height = content_height;
710 self.viewport_height = viewport_height;
711 }
712
713 pub fn set_highlights(&mut self, ranges: &[HighlightRange]) {
718 self.highlights.clear();
719 self.highlights.extend_from_slice(ranges);
720 self.current_highlight = if self.highlights.is_empty() {
721 None
722 } else {
723 Some(0)
724 };
725 }
726
727 pub fn highlights(&self) -> &[HighlightRange] {
729 &self.highlights
730 }
731
732 pub fn current_highlight(&self) -> Option<usize> {
734 self.current_highlight
735 }
736
737 pub fn clear_highlights(&mut self) {
739 self.highlights.clear();
740 self.current_highlight = None;
741 }
742
743 pub fn highlight_next(&mut self) {
746 if self.highlights.is_empty() {
747 return;
748 }
749 let next = match self.current_highlight {
750 Some(i) => (i + 1) % self.highlights.len(),
751 None => 0,
752 };
753 self.current_highlight = Some(next);
754 self.scroll_to_current_highlight();
755 }
756
757 pub fn highlight_previous(&mut self) {
760 if self.highlights.is_empty() {
761 return;
762 }
763 let next = match self.current_highlight {
764 Some(i) => {
765 if i == 0 {
766 self.highlights.len() - 1
767 } else {
768 i - 1
769 }
770 }
771 None => 0,
772 };
773 self.current_highlight = Some(next);
774 self.scroll_to_current_highlight();
775 }
776
777 pub fn scroll_to_current_highlight(&mut self) {
780 let Some(idx) = self.current_highlight else {
781 return;
782 };
783 let Some(range) = self.highlights.get(idx).copied() else {
784 return;
785 };
786 let target = range.start_line;
787 let viewport = self.viewport_height as usize;
788 let content = self.content_height as usize;
789 let max_offset = content.saturating_sub(viewport);
790 if target < self.offset {
791 self.offset = target.saturating_sub(1).min(max_offset);
792 } else if viewport > 0 && target >= self.offset + viewport {
793 let desired = target + 2;
794 let new_offset = desired.saturating_sub(viewport);
795 self.offset = new_offset.min(max_offset);
796 } else if self.offset > max_offset {
797 self.offset = max_offset;
798 }
799 }
800}
801
802impl Default for ScrollState {
803 fn default() -> Self {
804 Self::new()
805 }
806}
807
808#[derive(Debug, Clone, PartialEq)]
815pub struct SplitPaneState {
816 pub ratio: f64,
819 pub dragging: bool,
821 pub min_ratio: f64,
823}
824
825pub(crate) const DEFAULT_SPLIT_MIN_RATIO: f64 = 0.10;
831
832impl SplitPaneState {
833 pub fn new(ratio: f64) -> Self {
837 let min_ratio = DEFAULT_SPLIT_MIN_RATIO;
838 let clamped = ratio.clamp(min_ratio, 1.0 - min_ratio);
839 Self {
840 ratio: clamped,
841 dragging: false,
842 min_ratio,
843 }
844 }
845
846 pub fn with_min_ratio(mut self, min: f64) -> Self {
848 self.min_ratio = min.clamp(0.0, 0.49);
849 self.ratio = self.ratio.clamp(self.min_ratio, 1.0 - self.min_ratio);
850 self
851 }
852
853 pub fn set_ratio(&mut self, ratio: f64) {
855 self.ratio = ratio.clamp(self.min_ratio, 1.0 - self.min_ratio);
856 }
857}
858
859impl Default for SplitPaneState {
860 fn default() -> Self {
861 Self::new(0.5)
862 }
863}
864
865#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
885pub enum GridColumn {
886 Auto,
888 Fixed(u32),
890 Grow(u16),
893 Percent(u8),
895}