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}
15
16impl ListState {
17 pub fn new(items: Vec<impl Into<String>>) -> Self {
19 let len = items.len();
20 Self {
21 items: items.into_iter().map(Into::into).collect(),
22 selected: 0,
23 filter: String::new(),
24 view_indices: (0..len).collect(),
25 }
26 }
27
28 pub fn set_items(&mut self, items: Vec<impl Into<String>>) {
33 self.items = items.into_iter().map(Into::into).collect();
34 self.selected = self.selected.min(self.items.len().saturating_sub(1));
35 self.rebuild_view();
36 }
37
38 pub fn set_filter(&mut self, filter: impl Into<String>) {
42 self.filter = filter.into();
43 self.rebuild_view();
44 }
45
46 pub fn visible_indices(&self) -> &[usize] {
48 &self.view_indices
49 }
50
51 pub fn selected_item(&self) -> Option<&str> {
53 let data_idx = *self.view_indices.get(self.selected)?;
54 self.items.get(data_idx).map(String::as_str)
55 }
56
57 fn rebuild_view(&mut self) {
58 let tokens: Vec<String> = self
59 .filter
60 .split_whitespace()
61 .map(|t| t.to_lowercase())
62 .collect();
63 self.view_indices = if tokens.is_empty() {
64 (0..self.items.len()).collect()
65 } else {
66 (0..self.items.len())
67 .filter(|&i| {
68 tokens
69 .iter()
70 .all(|token| self.items[i].to_lowercase().contains(token.as_str()))
71 })
72 .collect()
73 };
74 if !self.view_indices.is_empty() && self.selected >= self.view_indices.len() {
75 self.selected = self.view_indices.len() - 1;
76 }
77 }
78}
79
80#[derive(Debug, Clone)]
84pub struct FilePickerState {
85 pub current_dir: PathBuf,
87 pub entries: Vec<FileEntry>,
89 pub selected: usize,
91 pub selected_file: Option<PathBuf>,
93 pub show_hidden: bool,
95 pub extensions: Vec<String>,
97 pub dirty: bool,
99}
100
101#[derive(Debug, Clone, Default)]
103pub struct FileEntry {
104 pub name: String,
106 pub path: PathBuf,
108 pub is_dir: bool,
110 pub size: u64,
112}
113
114impl FilePickerState {
115 pub fn new(dir: impl Into<PathBuf>) -> Self {
117 Self {
118 current_dir: dir.into(),
119 entries: Vec::new(),
120 selected: 0,
121 selected_file: None,
122 show_hidden: false,
123 extensions: Vec::new(),
124 dirty: true,
125 }
126 }
127
128 pub fn show_hidden(mut self, show: bool) -> Self {
130 self.show_hidden = show;
131 self.dirty = true;
132 self
133 }
134
135 pub fn extensions(mut self, exts: &[&str]) -> Self {
137 self.extensions = exts
138 .iter()
139 .map(|ext| ext.trim().trim_start_matches('.').to_ascii_lowercase())
140 .filter(|ext| !ext.is_empty())
141 .collect();
142 self.dirty = true;
143 self
144 }
145
146 pub fn selected(&self) -> Option<&PathBuf> {
148 self.selected_file.as_ref()
149 }
150
151 pub fn refresh(&mut self) {
153 let mut entries = Vec::new();
154
155 if let Ok(read_dir) = fs::read_dir(&self.current_dir) {
156 for dir_entry in read_dir.flatten() {
157 let name = dir_entry.file_name().to_string_lossy().to_string();
158 if !self.show_hidden && name.starts_with('.') {
159 continue;
160 }
161
162 let Ok(file_type) = dir_entry.file_type() else {
163 continue;
164 };
165 if file_type.is_symlink() {
166 continue;
167 }
168
169 let path = dir_entry.path();
170 let is_dir = file_type.is_dir();
171
172 if !is_dir && !self.extensions.is_empty() {
173 let ext = path
174 .extension()
175 .and_then(|e| e.to_str())
176 .map(|e| e.to_ascii_lowercase());
177 let Some(ext) = ext else {
178 continue;
179 };
180 if !self.extensions.iter().any(|allowed| allowed == &ext) {
181 continue;
182 }
183 }
184
185 let size = if is_dir {
186 0
187 } else {
188 fs::symlink_metadata(&path).map(|m| m.len()).unwrap_or(0)
189 };
190
191 entries.push(FileEntry {
192 name,
193 path,
194 is_dir,
195 size,
196 });
197 }
198 }
199
200 entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
201 (true, false) => std::cmp::Ordering::Less,
202 (false, true) => std::cmp::Ordering::Greater,
203 _ => a
204 .name
205 .to_ascii_lowercase()
206 .cmp(&b.name.to_ascii_lowercase())
207 .then_with(|| a.name.cmp(&b.name)),
208 });
209
210 self.entries = entries;
211 if self.entries.is_empty() {
212 self.selected = 0;
213 } else {
214 self.selected = self.selected.min(self.entries.len().saturating_sub(1));
215 }
216 self.dirty = false;
217 }
218}
219
220impl Default for FilePickerState {
221 fn default() -> Self {
222 Self::new(".")
223 }
224}
225
226#[derive(Debug, Clone, Default)]
231pub struct TabsState {
232 pub labels: Vec<String>,
234 pub selected: usize,
236}
237
238impl TabsState {
239 pub fn new(labels: Vec<impl Into<String>>) -> Self {
241 Self {
242 labels: labels.into_iter().map(Into::into).collect(),
243 selected: 0,
244 }
245 }
246
247 pub fn selected_label(&self) -> Option<&str> {
249 self.labels.get(self.selected).map(String::as_str)
250 }
251}
252
253#[derive(Debug, Clone)]
259pub struct TableState {
260 pub headers: Vec<String>,
262 pub rows: Vec<Vec<String>>,
264 pub selected: usize,
266 column_widths: Vec<u32>,
267 widths_dirty: bool,
268 pub sort_column: Option<usize>,
270 pub sort_ascending: bool,
272 pub filter: String,
274 pub page: usize,
276 pub page_size: usize,
278 pub zebra: bool,
280 view_indices: Vec<usize>,
281 row_search_cache: Vec<String>,
282 filter_tokens: Vec<String>,
283}
284
285impl Default for TableState {
286 fn default() -> Self {
287 Self {
288 headers: Vec::new(),
289 rows: Vec::new(),
290 selected: 0,
291 column_widths: Vec::new(),
292 widths_dirty: true,
293 sort_column: None,
294 sort_ascending: true,
295 filter: String::new(),
296 page: 0,
297 page_size: 0,
298 zebra: false,
299 view_indices: Vec::new(),
300 row_search_cache: Vec::new(),
301 filter_tokens: Vec::new(),
302 }
303 }
304}
305
306impl TableState {
307 pub fn new(headers: Vec<impl Into<String>>, rows: Vec<Vec<impl Into<String>>>) -> Self {
309 let headers: Vec<String> = headers.into_iter().map(Into::into).collect();
310 let rows: Vec<Vec<String>> = rows
311 .into_iter()
312 .map(|r| r.into_iter().map(Into::into).collect())
313 .collect();
314 let mut state = Self {
315 headers,
316 rows,
317 selected: 0,
318 column_widths: Vec::new(),
319 widths_dirty: true,
320 sort_column: None,
321 sort_ascending: true,
322 filter: String::new(),
323 page: 0,
324 page_size: 0,
325 zebra: false,
326 view_indices: Vec::new(),
327 row_search_cache: Vec::new(),
328 filter_tokens: Vec::new(),
329 };
330 state.rebuild_row_search_cache();
331 state.rebuild_view();
332 state.recompute_widths();
333 state
334 }
335
336 pub fn set_rows(&mut self, rows: Vec<Vec<impl Into<String>>>) {
341 self.rows = rows
342 .into_iter()
343 .map(|r| r.into_iter().map(Into::into).collect())
344 .collect();
345 self.rebuild_row_search_cache();
346 self.rebuild_view();
347 }
348
349 pub fn toggle_sort(&mut self, column: usize) {
351 if self.sort_column == Some(column) {
352 self.sort_ascending = !self.sort_ascending;
353 } else {
354 self.sort_column = Some(column);
355 self.sort_ascending = true;
356 }
357 self.rebuild_view();
358 }
359
360 pub fn sort_by(&mut self, column: usize) {
362 if self.sort_column == Some(column) && self.sort_ascending {
363 return;
364 }
365 self.sort_column = Some(column);
366 self.sort_ascending = true;
367 self.rebuild_view();
368 }
369
370 pub fn set_filter(&mut self, filter: impl Into<String>) {
374 let filter = filter.into();
375 if self.filter == filter {
376 return;
377 }
378 self.filter = filter;
379 self.filter_tokens = Self::tokenize_filter(&self.filter);
380 self.page = 0;
381 self.rebuild_view();
382 }
383
384 pub fn clear_sort(&mut self) {
386 if self.sort_column.is_none() && self.sort_ascending {
387 return;
388 }
389 self.sort_column = None;
390 self.sort_ascending = true;
391 self.rebuild_view();
392 }
393
394 pub fn next_page(&mut self) {
396 if self.page_size == 0 {
397 return;
398 }
399 let last_page = self.total_pages().saturating_sub(1);
400 self.page = (self.page + 1).min(last_page);
401 }
402
403 pub fn prev_page(&mut self) {
405 self.page = self.page.saturating_sub(1);
406 }
407
408 pub fn total_pages(&self) -> usize {
410 if self.page_size == 0 {
411 return 1;
412 }
413
414 let len = self.view_indices.len();
415 if len == 0 {
416 1
417 } else {
418 len.div_ceil(self.page_size)
419 }
420 }
421
422 pub fn visible_indices(&self) -> &[usize] {
424 &self.view_indices
425 }
426
427 pub fn selected_row(&self) -> Option<&[String]> {
429 if self.view_indices.is_empty() {
430 return None;
431 }
432 let data_idx = self.view_indices.get(self.selected)?;
433 self.rows.get(*data_idx).map(|r| r.as_slice())
434 }
435
436 fn rebuild_view(&mut self) {
438 let mut indices: Vec<usize> = (0..self.rows.len()).collect();
439
440 if !self.filter_tokens.is_empty() {
441 indices.retain(|&idx| {
442 let searchable = match self.row_search_cache.get(idx) {
443 Some(row) => row,
444 None => return false,
445 };
446 self.filter_tokens
447 .iter()
448 .all(|token| searchable.contains(token.as_str()))
449 });
450 }
451
452 if let Some(column) = self.sort_column {
453 indices.sort_by(|a, b| {
454 let left = self
455 .rows
456 .get(*a)
457 .and_then(|row| row.get(column))
458 .map(String::as_str)
459 .unwrap_or("");
460 let right = self
461 .rows
462 .get(*b)
463 .and_then(|row| row.get(column))
464 .map(String::as_str)
465 .unwrap_or("");
466
467 match (left.parse::<f64>(), right.parse::<f64>()) {
468 (Ok(l), Ok(r)) => l.partial_cmp(&r).unwrap_or(std::cmp::Ordering::Equal),
469 _ => left
470 .chars()
471 .flat_map(char::to_lowercase)
472 .cmp(right.chars().flat_map(char::to_lowercase)),
473 }
474 });
475
476 if !self.sort_ascending {
477 indices.reverse();
478 }
479 }
480
481 self.view_indices = indices;
482
483 if self.page_size > 0 {
484 self.page = self.page.min(self.total_pages().saturating_sub(1));
485 } else {
486 self.page = 0;
487 }
488
489 self.selected = self.selected.min(self.view_indices.len().saturating_sub(1));
490 self.widths_dirty = true;
491 }
492
493 fn rebuild_row_search_cache(&mut self) {
494 self.row_search_cache = self
495 .rows
496 .iter()
497 .map(|row| {
498 let mut searchable = String::new();
499 for (idx, cell) in row.iter().enumerate() {
500 if idx > 0 {
501 searchable.push('\n');
502 }
503 searchable.extend(cell.chars().flat_map(char::to_lowercase));
504 }
505 searchable
506 })
507 .collect();
508 self.filter_tokens = Self::tokenize_filter(&self.filter);
509 self.widths_dirty = true;
510 }
511
512 fn tokenize_filter(filter: &str) -> Vec<String> {
513 filter
514 .split_whitespace()
515 .map(|t| t.to_lowercase())
516 .collect()
517 }
518
519 pub(crate) fn recompute_widths(&mut self) {
520 let col_count = self.headers.len();
521 self.column_widths = vec![0u32; col_count];
522 for (i, header) in self.headers.iter().enumerate() {
523 let mut width = UnicodeWidthStr::width(header.as_str()) as u32;
524 if self.sort_column == Some(i) {
525 width += 2;
526 }
527 self.column_widths[i] = width;
528 }
529 for row in &self.rows {
530 for (i, cell) in row.iter().enumerate() {
531 if i < col_count {
532 let w = UnicodeWidthStr::width(cell.as_str()) as u32;
533 self.column_widths[i] = self.column_widths[i].max(w);
534 }
535 }
536 }
537 self.widths_dirty = false;
538 }
539
540 pub(crate) fn column_widths(&self) -> &[u32] {
541 &self.column_widths
542 }
543
544 pub(crate) fn is_dirty(&self) -> bool {
545 self.widths_dirty
546 }
547}
548
549#[derive(Debug, Clone)]
555pub struct ScrollState {
556 pub offset: usize,
558 content_height: u32,
559 viewport_height: u32,
560}
561
562impl ScrollState {
563 pub fn new() -> Self {
565 Self {
566 offset: 0,
567 content_height: 0,
568 viewport_height: 0,
569 }
570 }
571
572 pub fn can_scroll_up(&self) -> bool {
574 self.offset > 0
575 }
576
577 pub fn can_scroll_down(&self) -> bool {
579 (self.offset as u32) + self.viewport_height < self.content_height
580 }
581
582 pub fn content_height(&self) -> u32 {
584 self.content_height
585 }
586
587 pub fn viewport_height(&self) -> u32 {
589 self.viewport_height
590 }
591
592 pub fn progress(&self) -> f32 {
594 let max = self.content_height.saturating_sub(self.viewport_height);
595 if max == 0 {
596 0.0
597 } else {
598 self.offset as f32 / max as f32
599 }
600 }
601
602 pub fn scroll_up(&mut self, amount: usize) {
604 self.offset = self.offset.saturating_sub(amount);
605 }
606
607 pub fn scroll_down(&mut self, amount: usize) {
609 let max_offset = self.content_height.saturating_sub(self.viewport_height) as usize;
610 self.offset = (self.offset + amount).min(max_offset);
611 }
612
613 pub(crate) fn set_bounds(&mut self, content_height: u32, viewport_height: u32) {
614 self.content_height = content_height;
615 self.viewport_height = viewport_height;
616 }
617}
618
619impl Default for ScrollState {
620 fn default() -> Self {
621 Self::new()
622 }
623}