1use std::collections::HashSet;
8use std::fs;
9use std::path::PathBuf;
10use std::time::{SystemTime, UNIX_EPOCH};
11use unicode_width::UnicodeWidthStr;
12
13type FormValidator = fn(&str) -> Result<(), String>;
14type TextInputValidator = Box<dyn Fn(&str) -> Result<(), String>>;
15
16#[derive(Debug, Clone, Default)]
20pub struct StaticOutput {
21 lines: Vec<String>,
22 new_lines: Vec<String>,
23}
24
25impl StaticOutput {
26 pub fn new() -> Self {
28 Self::default()
29 }
30
31 pub fn println(&mut self, line: impl Into<String>) {
33 let line = line.into();
34 self.lines.push(line.clone());
35 self.new_lines.push(line);
36 }
37
38 pub fn lines(&self) -> &[String] {
40 &self.lines
41 }
42
43 pub fn drain_new(&mut self) -> Vec<String> {
45 std::mem::take(&mut self.new_lines)
46 }
47
48 pub fn clear(&mut self) {
50 self.lines.clear();
51 self.new_lines.clear();
52 }
53}
54
55pub struct TextInputState {
71 pub value: String,
73 pub cursor: usize,
75 pub placeholder: String,
77 pub max_length: Option<usize>,
79 pub validation_error: Option<String>,
81 pub masked: bool,
83 pub suggestions: Vec<String>,
85 pub suggestion_index: usize,
87 pub show_suggestions: bool,
89 validators: Vec<TextInputValidator>,
91 validation_errors: Vec<String>,
93}
94
95impl std::fmt::Debug for TextInputState {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 f.debug_struct("TextInputState")
98 .field("value", &self.value)
99 .field("cursor", &self.cursor)
100 .field("placeholder", &self.placeholder)
101 .field("max_length", &self.max_length)
102 .field("validation_error", &self.validation_error)
103 .field("masked", &self.masked)
104 .field("suggestions", &self.suggestions)
105 .field("suggestion_index", &self.suggestion_index)
106 .field("show_suggestions", &self.show_suggestions)
107 .field("validators_len", &self.validators.len())
108 .field("validation_errors", &self.validation_errors)
109 .finish()
110 }
111}
112
113impl Clone for TextInputState {
114 fn clone(&self) -> Self {
115 Self {
116 value: self.value.clone(),
117 cursor: self.cursor,
118 placeholder: self.placeholder.clone(),
119 max_length: self.max_length,
120 validation_error: self.validation_error.clone(),
121 masked: self.masked,
122 suggestions: self.suggestions.clone(),
123 suggestion_index: self.suggestion_index,
124 show_suggestions: self.show_suggestions,
125 validators: Vec::new(),
126 validation_errors: self.validation_errors.clone(),
127 }
128 }
129}
130
131impl TextInputState {
132 pub fn new() -> Self {
134 Self {
135 value: String::new(),
136 cursor: 0,
137 placeholder: String::new(),
138 max_length: None,
139 validation_error: None,
140 masked: false,
141 suggestions: Vec::new(),
142 suggestion_index: 0,
143 show_suggestions: false,
144 validators: Vec::new(),
145 validation_errors: Vec::new(),
146 }
147 }
148
149 pub fn with_placeholder(p: impl Into<String>) -> Self {
151 Self {
152 placeholder: p.into(),
153 ..Self::new()
154 }
155 }
156
157 pub fn max_length(mut self, len: usize) -> Self {
159 self.max_length = Some(len);
160 self
161 }
162
163 pub fn validate(&mut self, validator: impl Fn(&str) -> Result<(), String>) {
171 self.validation_error = validator(&self.value).err();
172 }
173
174 pub fn add_validator(&mut self, f: impl Fn(&str) -> Result<(), String> + 'static) {
179 self.validators.push(Box::new(f));
180 }
181
182 pub fn run_validators(&mut self) {
187 self.validation_errors.clear();
188 for validator in &self.validators {
189 if let Err(err) = validator(&self.value) {
190 self.validation_errors.push(err);
191 }
192 }
193 self.validation_error = self.validation_errors.first().cloned();
194 }
195
196 pub fn errors(&self) -> &[String] {
198 &self.validation_errors
199 }
200
201 pub fn set_suggestions(&mut self, suggestions: Vec<String>) {
203 self.suggestions = suggestions;
204 self.suggestion_index = 0;
205 self.show_suggestions = !self.suggestions.is_empty();
206 }
207
208 pub fn matched_suggestions(&self) -> Vec<&str> {
210 if self.value.is_empty() {
211 return Vec::new();
212 }
213 let lower = self.value.to_lowercase();
214 self.suggestions
215 .iter()
216 .filter(|s| s.to_lowercase().starts_with(&lower))
217 .map(|s| s.as_str())
218 .collect()
219 }
220}
221
222impl Default for TextInputState {
223 fn default() -> Self {
224 Self::new()
225 }
226}
227
228#[derive(Debug, Default)]
230pub struct FormField {
231 pub label: String,
233 pub input: TextInputState,
235 pub error: Option<String>,
237}
238
239impl FormField {
240 pub fn new(label: impl Into<String>) -> Self {
242 Self {
243 label: label.into(),
244 input: TextInputState::new(),
245 error: None,
246 }
247 }
248
249 pub fn placeholder(mut self, p: impl Into<String>) -> Self {
251 self.input.placeholder = p.into();
252 self
253 }
254}
255
256#[derive(Debug)]
258pub struct FormState {
259 pub fields: Vec<FormField>,
261 pub submitted: bool,
263}
264
265impl FormState {
266 pub fn new() -> Self {
268 Self {
269 fields: Vec::new(),
270 submitted: false,
271 }
272 }
273
274 pub fn field(mut self, field: FormField) -> Self {
276 self.fields.push(field);
277 self
278 }
279
280 pub fn validate(&mut self, validators: &[FormValidator]) -> bool {
284 let mut all_valid = true;
285 for (i, field) in self.fields.iter_mut().enumerate() {
286 if let Some(validator) = validators.get(i) {
287 match validator(&field.input.value) {
288 Ok(()) => field.error = None,
289 Err(msg) => {
290 field.error = Some(msg);
291 all_valid = false;
292 }
293 }
294 }
295 }
296 all_valid
297 }
298
299 pub fn value(&self, index: usize) -> &str {
301 self.fields
302 .get(index)
303 .map(|f| f.input.value.as_str())
304 .unwrap_or("")
305 }
306}
307
308impl Default for FormState {
309 fn default() -> Self {
310 Self::new()
311 }
312}
313
314#[derive(Debug, Clone)]
320pub struct ToastState {
321 pub messages: Vec<ToastMessage>,
323}
324
325#[derive(Debug, Clone)]
327pub struct ToastMessage {
328 pub text: String,
330 pub level: ToastLevel,
332 pub created_tick: u64,
334 pub duration_ticks: u64,
336}
337
338impl Default for ToastMessage {
339 fn default() -> Self {
340 Self {
341 text: String::new(),
342 level: ToastLevel::Info,
343 created_tick: 0,
344 duration_ticks: 30,
345 }
346 }
347}
348
349#[derive(Debug, Clone, Copy, PartialEq, Eq)]
351pub enum ToastLevel {
352 Info,
354 Success,
356 Warning,
358 Error,
360}
361
362#[derive(Debug, Clone, Copy, PartialEq, Eq)]
363pub enum AlertLevel {
364 Info,
366 Success,
368 Warning,
370 Error,
372}
373
374impl ToastState {
375 pub fn new() -> Self {
377 Self {
378 messages: Vec::new(),
379 }
380 }
381
382 pub fn info(&mut self, text: impl Into<String>, tick: u64) {
384 self.push(text, ToastLevel::Info, tick, 30);
385 }
386
387 pub fn success(&mut self, text: impl Into<String>, tick: u64) {
389 self.push(text, ToastLevel::Success, tick, 30);
390 }
391
392 pub fn warning(&mut self, text: impl Into<String>, tick: u64) {
394 self.push(text, ToastLevel::Warning, tick, 50);
395 }
396
397 pub fn error(&mut self, text: impl Into<String>, tick: u64) {
399 self.push(text, ToastLevel::Error, tick, 80);
400 }
401
402 pub fn push(
404 &mut self,
405 text: impl Into<String>,
406 level: ToastLevel,
407 tick: u64,
408 duration_ticks: u64,
409 ) {
410 self.messages.push(ToastMessage {
411 text: text.into(),
412 level,
413 created_tick: tick,
414 duration_ticks,
415 });
416 }
417
418 pub fn cleanup(&mut self, current_tick: u64) {
422 self.messages.retain(|message| {
423 current_tick < message.created_tick.saturating_add(message.duration_ticks)
424 });
425 }
426}
427
428impl Default for ToastState {
429 fn default() -> Self {
430 Self::new()
431 }
432}
433
434#[derive(Debug, Clone)]
439pub struct TextareaState {
440 pub lines: Vec<String>,
442 pub cursor_row: usize,
444 pub cursor_col: usize,
446 pub max_length: Option<usize>,
448 pub wrap_width: Option<u32>,
450 pub scroll_offset: usize,
452}
453
454impl TextareaState {
455 pub fn new() -> Self {
457 Self {
458 lines: vec![String::new()],
459 cursor_row: 0,
460 cursor_col: 0,
461 max_length: None,
462 wrap_width: None,
463 scroll_offset: 0,
464 }
465 }
466
467 pub fn value(&self) -> String {
469 self.lines.join("\n")
470 }
471
472 pub fn set_value(&mut self, text: impl Into<String>) {
476 let value = text.into();
477 self.lines = value.split('\n').map(str::to_string).collect();
478 if self.lines.is_empty() {
479 self.lines.push(String::new());
480 }
481 self.cursor_row = 0;
482 self.cursor_col = 0;
483 self.scroll_offset = 0;
484 }
485
486 pub fn max_length(mut self, len: usize) -> Self {
488 self.max_length = Some(len);
489 self
490 }
491
492 pub fn word_wrap(mut self, width: u32) -> Self {
494 self.wrap_width = Some(width);
495 self
496 }
497}
498
499impl Default for TextareaState {
500 fn default() -> Self {
501 Self::new()
502 }
503}
504
505#[derive(Debug, Clone)]
511pub struct SpinnerState {
512 chars: Vec<char>,
513}
514
515impl SpinnerState {
516 pub fn dots() -> Self {
520 Self {
521 chars: vec!['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
522 }
523 }
524
525 pub fn line() -> Self {
529 Self {
530 chars: vec!['|', '/', '-', '\\'],
531 }
532 }
533
534 pub fn frame(&self, tick: u64) -> char {
536 if self.chars.is_empty() {
537 return ' ';
538 }
539 self.chars[tick as usize % self.chars.len()]
540 }
541}
542
543impl Default for SpinnerState {
544 fn default() -> Self {
545 Self::dots()
546 }
547}
548
549#[derive(Debug, Clone, Default)]
554pub struct ListState {
555 pub items: Vec<String>,
557 pub selected: usize,
559 pub filter: String,
561 view_indices: Vec<usize>,
562}
563
564impl ListState {
565 pub fn new(items: Vec<impl Into<String>>) -> Self {
567 let len = items.len();
568 Self {
569 items: items.into_iter().map(Into::into).collect(),
570 selected: 0,
571 filter: String::new(),
572 view_indices: (0..len).collect(),
573 }
574 }
575
576 pub fn set_items(&mut self, items: Vec<impl Into<String>>) {
581 self.items = items.into_iter().map(Into::into).collect();
582 self.selected = self.selected.min(self.items.len().saturating_sub(1));
583 self.rebuild_view();
584 }
585
586 pub fn set_filter(&mut self, filter: impl Into<String>) {
590 self.filter = filter.into();
591 self.rebuild_view();
592 }
593
594 pub fn visible_indices(&self) -> &[usize] {
596 &self.view_indices
597 }
598
599 pub fn selected_item(&self) -> Option<&str> {
601 let data_idx = *self.view_indices.get(self.selected)?;
602 self.items.get(data_idx).map(String::as_str)
603 }
604
605 fn rebuild_view(&mut self) {
606 let tokens: Vec<String> = self
607 .filter
608 .split_whitespace()
609 .map(|t| t.to_lowercase())
610 .collect();
611 self.view_indices = if tokens.is_empty() {
612 (0..self.items.len()).collect()
613 } else {
614 (0..self.items.len())
615 .filter(|&i| {
616 tokens
617 .iter()
618 .all(|token| self.items[i].to_lowercase().contains(token.as_str()))
619 })
620 .collect()
621 };
622 if !self.view_indices.is_empty() && self.selected >= self.view_indices.len() {
623 self.selected = self.view_indices.len() - 1;
624 }
625 }
626}
627
628#[derive(Debug, Clone)]
632pub struct FilePickerState {
633 pub current_dir: PathBuf,
635 pub entries: Vec<FileEntry>,
637 pub selected: usize,
639 pub selected_file: Option<PathBuf>,
641 pub show_hidden: bool,
643 pub extensions: Vec<String>,
645 pub dirty: bool,
647}
648
649#[derive(Debug, Clone, Default)]
651pub struct FileEntry {
652 pub name: String,
654 pub path: PathBuf,
656 pub is_dir: bool,
658 pub size: u64,
660}
661
662impl FilePickerState {
663 pub fn new(dir: impl Into<PathBuf>) -> Self {
665 Self {
666 current_dir: dir.into(),
667 entries: Vec::new(),
668 selected: 0,
669 selected_file: None,
670 show_hidden: false,
671 extensions: Vec::new(),
672 dirty: true,
673 }
674 }
675
676 pub fn show_hidden(mut self, show: bool) -> Self {
678 self.show_hidden = show;
679 self.dirty = true;
680 self
681 }
682
683 pub fn extensions(mut self, exts: &[&str]) -> Self {
685 self.extensions = exts
686 .iter()
687 .map(|ext| ext.trim().trim_start_matches('.').to_ascii_lowercase())
688 .filter(|ext| !ext.is_empty())
689 .collect();
690 self.dirty = true;
691 self
692 }
693
694 pub fn selected(&self) -> Option<&PathBuf> {
696 self.selected_file.as_ref()
697 }
698
699 pub fn refresh(&mut self) {
701 let mut entries = Vec::new();
702
703 if let Ok(read_dir) = fs::read_dir(&self.current_dir) {
704 for dir_entry in read_dir.flatten() {
705 let name = dir_entry.file_name().to_string_lossy().to_string();
706 if !self.show_hidden && name.starts_with('.') {
707 continue;
708 }
709
710 let Ok(file_type) = dir_entry.file_type() else {
711 continue;
712 };
713 if file_type.is_symlink() {
714 continue;
715 }
716
717 let path = dir_entry.path();
718 let is_dir = file_type.is_dir();
719
720 if !is_dir && !self.extensions.is_empty() {
721 let ext = path
722 .extension()
723 .and_then(|e| e.to_str())
724 .map(|e| e.to_ascii_lowercase());
725 let Some(ext) = ext else {
726 continue;
727 };
728 if !self.extensions.iter().any(|allowed| allowed == &ext) {
729 continue;
730 }
731 }
732
733 let size = if is_dir {
734 0
735 } else {
736 fs::symlink_metadata(&path).map(|m| m.len()).unwrap_or(0)
737 };
738
739 entries.push(FileEntry {
740 name,
741 path,
742 is_dir,
743 size,
744 });
745 }
746 }
747
748 entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
749 (true, false) => std::cmp::Ordering::Less,
750 (false, true) => std::cmp::Ordering::Greater,
751 _ => a
752 .name
753 .to_ascii_lowercase()
754 .cmp(&b.name.to_ascii_lowercase())
755 .then_with(|| a.name.cmp(&b.name)),
756 });
757
758 self.entries = entries;
759 if self.entries.is_empty() {
760 self.selected = 0;
761 } else {
762 self.selected = self.selected.min(self.entries.len().saturating_sub(1));
763 }
764 self.dirty = false;
765 }
766}
767
768impl Default for FilePickerState {
769 fn default() -> Self {
770 Self::new(".")
771 }
772}
773
774#[derive(Debug, Clone, Default)]
779pub struct TabsState {
780 pub labels: Vec<String>,
782 pub selected: usize,
784}
785
786impl TabsState {
787 pub fn new(labels: Vec<impl Into<String>>) -> Self {
789 Self {
790 labels: labels.into_iter().map(Into::into).collect(),
791 selected: 0,
792 }
793 }
794
795 pub fn selected_label(&self) -> Option<&str> {
797 self.labels.get(self.selected).map(String::as_str)
798 }
799}
800
801#[derive(Debug, Clone)]
807pub struct TableState {
808 pub headers: Vec<String>,
810 pub rows: Vec<Vec<String>>,
812 pub selected: usize,
814 column_widths: Vec<u32>,
815 dirty: bool,
816 pub sort_column: Option<usize>,
818 pub sort_ascending: bool,
820 pub filter: String,
822 pub page: usize,
824 pub page_size: usize,
826 pub zebra: bool,
828 view_indices: Vec<usize>,
829}
830
831impl Default for TableState {
832 fn default() -> Self {
833 Self {
834 headers: Vec::new(),
835 rows: Vec::new(),
836 selected: 0,
837 column_widths: Vec::new(),
838 dirty: true,
839 sort_column: None,
840 sort_ascending: true,
841 filter: String::new(),
842 page: 0,
843 page_size: 0,
844 zebra: false,
845 view_indices: Vec::new(),
846 }
847 }
848}
849
850impl TableState {
851 pub fn new(headers: Vec<impl Into<String>>, rows: Vec<Vec<impl Into<String>>>) -> Self {
853 let headers: Vec<String> = headers.into_iter().map(Into::into).collect();
854 let rows: Vec<Vec<String>> = rows
855 .into_iter()
856 .map(|r| r.into_iter().map(Into::into).collect())
857 .collect();
858 let mut state = Self {
859 headers,
860 rows,
861 selected: 0,
862 column_widths: Vec::new(),
863 dirty: true,
864 sort_column: None,
865 sort_ascending: true,
866 filter: String::new(),
867 page: 0,
868 page_size: 0,
869 zebra: false,
870 view_indices: Vec::new(),
871 };
872 state.rebuild_view();
873 state.recompute_widths();
874 state
875 }
876
877 pub fn set_rows(&mut self, rows: Vec<Vec<impl Into<String>>>) {
882 self.rows = rows
883 .into_iter()
884 .map(|r| r.into_iter().map(Into::into).collect())
885 .collect();
886 self.rebuild_view();
887 }
888
889 pub fn toggle_sort(&mut self, column: usize) {
891 if self.sort_column == Some(column) {
892 self.sort_ascending = !self.sort_ascending;
893 } else {
894 self.sort_column = Some(column);
895 self.sort_ascending = true;
896 }
897 self.rebuild_view();
898 }
899
900 pub fn sort_by(&mut self, column: usize) {
902 self.sort_column = Some(column);
903 self.sort_ascending = true;
904 self.rebuild_view();
905 }
906
907 pub fn set_filter(&mut self, filter: impl Into<String>) {
911 self.filter = filter.into();
912 self.page = 0;
913 self.rebuild_view();
914 }
915
916 pub fn clear_sort(&mut self) {
918 self.sort_column = None;
919 self.sort_ascending = true;
920 self.rebuild_view();
921 }
922
923 pub fn next_page(&mut self) {
925 if self.page_size == 0 {
926 return;
927 }
928 let last_page = self.total_pages().saturating_sub(1);
929 self.page = (self.page + 1).min(last_page);
930 }
931
932 pub fn prev_page(&mut self) {
934 self.page = self.page.saturating_sub(1);
935 }
936
937 pub fn total_pages(&self) -> usize {
939 if self.page_size == 0 {
940 return 1;
941 }
942
943 let len = self.view_indices.len();
944 if len == 0 {
945 1
946 } else {
947 len.div_ceil(self.page_size)
948 }
949 }
950
951 pub fn visible_indices(&self) -> &[usize] {
953 &self.view_indices
954 }
955
956 pub fn selected_row(&self) -> Option<&[String]> {
958 if self.view_indices.is_empty() {
959 return None;
960 }
961 let data_idx = self.view_indices.get(self.selected)?;
962 self.rows.get(*data_idx).map(|r| r.as_slice())
963 }
964
965 fn rebuild_view(&mut self) {
967 let mut indices: Vec<usize> = (0..self.rows.len()).collect();
968
969 let tokens: Vec<String> = self
970 .filter
971 .split_whitespace()
972 .map(|t| t.to_lowercase())
973 .collect();
974 if !tokens.is_empty() {
975 indices.retain(|&idx| {
976 let row = match self.rows.get(idx) {
977 Some(r) => r,
978 None => return false,
979 };
980 tokens.iter().all(|token| {
981 row.iter()
982 .any(|cell| cell.to_lowercase().contains(token.as_str()))
983 })
984 });
985 }
986
987 if let Some(column) = self.sort_column {
988 indices.sort_by(|a, b| {
989 let left = self
990 .rows
991 .get(*a)
992 .and_then(|row| row.get(column))
993 .map(String::as_str)
994 .unwrap_or("");
995 let right = self
996 .rows
997 .get(*b)
998 .and_then(|row| row.get(column))
999 .map(String::as_str)
1000 .unwrap_or("");
1001
1002 match (left.parse::<f64>(), right.parse::<f64>()) {
1003 (Ok(l), Ok(r)) => l.partial_cmp(&r).unwrap_or(std::cmp::Ordering::Equal),
1004 _ => left.to_lowercase().cmp(&right.to_lowercase()),
1005 }
1006 });
1007
1008 if !self.sort_ascending {
1009 indices.reverse();
1010 }
1011 }
1012
1013 self.view_indices = indices;
1014
1015 if self.page_size > 0 {
1016 self.page = self.page.min(self.total_pages().saturating_sub(1));
1017 } else {
1018 self.page = 0;
1019 }
1020
1021 self.selected = self.selected.min(self.view_indices.len().saturating_sub(1));
1022 self.dirty = true;
1023 }
1024
1025 pub(crate) fn recompute_widths(&mut self) {
1026 let col_count = self.headers.len();
1027 self.column_widths = vec![0u32; col_count];
1028 for (i, header) in self.headers.iter().enumerate() {
1029 let mut width = UnicodeWidthStr::width(header.as_str()) as u32;
1030 if self.sort_column == Some(i) {
1031 width += 2;
1032 }
1033 self.column_widths[i] = width;
1034 }
1035 for row in &self.rows {
1036 for (i, cell) in row.iter().enumerate() {
1037 if i < col_count {
1038 let w = UnicodeWidthStr::width(cell.as_str()) as u32;
1039 self.column_widths[i] = self.column_widths[i].max(w);
1040 }
1041 }
1042 }
1043 self.dirty = false;
1044 }
1045
1046 pub(crate) fn column_widths(&self) -> &[u32] {
1047 &self.column_widths
1048 }
1049
1050 pub(crate) fn is_dirty(&self) -> bool {
1051 self.dirty
1052 }
1053}
1054
1055#[derive(Debug, Clone)]
1061pub struct ScrollState {
1062 pub offset: usize,
1064 content_height: u32,
1065 viewport_height: u32,
1066}
1067
1068impl ScrollState {
1069 pub fn new() -> Self {
1071 Self {
1072 offset: 0,
1073 content_height: 0,
1074 viewport_height: 0,
1075 }
1076 }
1077
1078 pub fn can_scroll_up(&self) -> bool {
1080 self.offset > 0
1081 }
1082
1083 pub fn can_scroll_down(&self) -> bool {
1085 (self.offset as u32) + self.viewport_height < self.content_height
1086 }
1087
1088 pub fn content_height(&self) -> u32 {
1090 self.content_height
1091 }
1092
1093 pub fn viewport_height(&self) -> u32 {
1095 self.viewport_height
1096 }
1097
1098 pub fn progress(&self) -> f32 {
1100 let max = self.content_height.saturating_sub(self.viewport_height);
1101 if max == 0 {
1102 0.0
1103 } else {
1104 self.offset as f32 / max as f32
1105 }
1106 }
1107
1108 pub fn scroll_up(&mut self, amount: usize) {
1110 self.offset = self.offset.saturating_sub(amount);
1111 }
1112
1113 pub fn scroll_down(&mut self, amount: usize) {
1115 let max_offset = self.content_height.saturating_sub(self.viewport_height) as usize;
1116 self.offset = (self.offset + amount).min(max_offset);
1117 }
1118
1119 pub(crate) fn set_bounds(&mut self, content_height: u32, viewport_height: u32) {
1120 self.content_height = content_height;
1121 self.viewport_height = viewport_height;
1122 }
1123}
1124
1125impl Default for ScrollState {
1126 fn default() -> Self {
1127 Self::new()
1128 }
1129}
1130
1131#[derive(Debug, Clone)]
1132pub struct CalendarState {
1133 pub year: i32,
1134 pub month: u32,
1135 pub selected_day: Option<u32>,
1136 pub(crate) cursor_day: u32,
1137}
1138
1139impl CalendarState {
1140 pub fn new() -> Self {
1141 let (year, month) = Self::current_year_month();
1142 Self::from_ym(year, month)
1143 }
1144
1145 pub fn from_ym(year: i32, month: u32) -> Self {
1146 let month = month.clamp(1, 12);
1147 Self {
1148 year,
1149 month,
1150 selected_day: None,
1151 cursor_day: 1,
1152 }
1153 }
1154
1155 pub fn selected_date(&self) -> Option<(i32, u32, u32)> {
1156 self.selected_day.map(|day| (self.year, self.month, day))
1157 }
1158
1159 pub fn prev_month(&mut self) {
1160 if self.month == 1 {
1161 self.month = 12;
1162 self.year -= 1;
1163 } else {
1164 self.month -= 1;
1165 }
1166 self.clamp_days();
1167 }
1168
1169 pub fn next_month(&mut self) {
1170 if self.month == 12 {
1171 self.month = 1;
1172 self.year += 1;
1173 } else {
1174 self.month += 1;
1175 }
1176 self.clamp_days();
1177 }
1178
1179 pub(crate) fn days_in_month(year: i32, month: u32) -> u32 {
1180 match month {
1181 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
1182 4 | 6 | 9 | 11 => 30,
1183 2 => {
1184 if Self::is_leap_year(year) {
1185 29
1186 } else {
1187 28
1188 }
1189 }
1190 _ => 30,
1191 }
1192 }
1193
1194 pub(crate) fn first_weekday(year: i32, month: u32) -> u32 {
1195 let month = month.clamp(1, 12);
1196 let offsets = [0_i32, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
1197 let mut y = year;
1198 if month < 3 {
1199 y -= 1;
1200 }
1201 let sunday_based = (y + y / 4 - y / 100 + y / 400 + offsets[(month - 1) as usize] + 1) % 7;
1202 ((sunday_based + 6) % 7) as u32
1203 }
1204
1205 fn clamp_days(&mut self) {
1206 let max_day = Self::days_in_month(self.year, self.month);
1207 self.cursor_day = self.cursor_day.clamp(1, max_day);
1208 if let Some(day) = self.selected_day {
1209 self.selected_day = Some(day.min(max_day));
1210 }
1211 }
1212
1213 fn is_leap_year(year: i32) -> bool {
1214 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
1215 }
1216
1217 fn current_year_month() -> (i32, u32) {
1218 let Ok(duration) = SystemTime::now().duration_since(UNIX_EPOCH) else {
1219 return (1970, 1);
1220 };
1221 let days_since_epoch = (duration.as_secs() / 86_400) as i64;
1222 let (year, month, _) = Self::civil_from_days(days_since_epoch);
1223 (year, month)
1224 }
1225
1226 fn civil_from_days(days_since_epoch: i64) -> (i32, u32, u32) {
1227 let z = days_since_epoch + 719_468;
1228 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1229 let doe = z - era * 146_097;
1230 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
1231 let mut year = (yoe as i32) + (era as i32) * 400;
1232 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1233 let mp = (5 * doy + 2) / 153;
1234 let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
1235 let month = (mp + if mp < 10 { 3 } else { -9 }) as u32;
1236 if month <= 2 {
1237 year += 1;
1238 }
1239 (year, month, day)
1240 }
1241}
1242
1243impl Default for CalendarState {
1244 fn default() -> Self {
1245 Self::new()
1246 }
1247}
1248
1249#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1259pub enum ButtonVariant {
1260 #[default]
1262 Default,
1263 Primary,
1265 Danger,
1267 Outline,
1269}
1270
1271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1272pub enum Trend {
1273 Up,
1275 Down,
1277}
1278
1279#[derive(Debug, Clone, Default)]
1286pub struct SelectState {
1287 pub items: Vec<String>,
1289 pub selected: usize,
1291 pub open: bool,
1293 pub placeholder: String,
1295 cursor: usize,
1296}
1297
1298impl SelectState {
1299 pub fn new(items: Vec<impl Into<String>>) -> Self {
1301 Self {
1302 items: items.into_iter().map(Into::into).collect(),
1303 selected: 0,
1304 open: false,
1305 placeholder: String::new(),
1306 cursor: 0,
1307 }
1308 }
1309
1310 pub fn placeholder(mut self, p: impl Into<String>) -> Self {
1312 self.placeholder = p.into();
1313 self
1314 }
1315
1316 pub fn selected_item(&self) -> Option<&str> {
1318 self.items.get(self.selected).map(String::as_str)
1319 }
1320
1321 pub(crate) fn cursor(&self) -> usize {
1322 self.cursor
1323 }
1324
1325 pub(crate) fn set_cursor(&mut self, c: usize) {
1326 self.cursor = c;
1327 }
1328}
1329
1330#[derive(Debug, Clone, Default)]
1336pub struct RadioState {
1337 pub items: Vec<String>,
1339 pub selected: usize,
1341}
1342
1343impl RadioState {
1344 pub fn new(items: Vec<impl Into<String>>) -> Self {
1346 Self {
1347 items: items.into_iter().map(Into::into).collect(),
1348 selected: 0,
1349 }
1350 }
1351
1352 pub fn selected_item(&self) -> Option<&str> {
1354 self.items.get(self.selected).map(String::as_str)
1355 }
1356}
1357
1358#[derive(Debug, Clone)]
1364pub struct MultiSelectState {
1365 pub items: Vec<String>,
1367 pub cursor: usize,
1369 pub selected: HashSet<usize>,
1371}
1372
1373impl MultiSelectState {
1374 pub fn new(items: Vec<impl Into<String>>) -> Self {
1376 Self {
1377 items: items.into_iter().map(Into::into).collect(),
1378 cursor: 0,
1379 selected: HashSet::new(),
1380 }
1381 }
1382
1383 pub fn selected_items(&self) -> Vec<&str> {
1385 let mut indices: Vec<usize> = self.selected.iter().copied().collect();
1386 indices.sort();
1387 indices
1388 .iter()
1389 .filter_map(|&i| self.items.get(i).map(String::as_str))
1390 .collect()
1391 }
1392
1393 pub fn toggle(&mut self, index: usize) {
1395 if self.selected.contains(&index) {
1396 self.selected.remove(&index);
1397 } else {
1398 self.selected.insert(index);
1399 }
1400 }
1401}
1402
1403#[derive(Debug, Clone)]
1407pub struct TreeNode {
1408 pub label: String,
1410 pub children: Vec<TreeNode>,
1412 pub expanded: bool,
1414}
1415
1416impl TreeNode {
1417 pub fn new(label: impl Into<String>) -> Self {
1419 Self {
1420 label: label.into(),
1421 children: Vec::new(),
1422 expanded: false,
1423 }
1424 }
1425
1426 pub fn expanded(mut self) -> Self {
1428 self.expanded = true;
1429 self
1430 }
1431
1432 pub fn children(mut self, children: Vec<TreeNode>) -> Self {
1434 self.children = children;
1435 self
1436 }
1437
1438 pub fn is_leaf(&self) -> bool {
1440 self.children.is_empty()
1441 }
1442
1443 fn flatten(&self, depth: usize, out: &mut Vec<FlatTreeEntry>) {
1444 out.push(FlatTreeEntry {
1445 depth,
1446 label: self.label.clone(),
1447 is_leaf: self.is_leaf(),
1448 expanded: self.expanded,
1449 });
1450 if self.expanded {
1451 for child in &self.children {
1452 child.flatten(depth + 1, out);
1453 }
1454 }
1455 }
1456}
1457
1458pub(crate) struct FlatTreeEntry {
1459 pub depth: usize,
1460 pub label: String,
1461 pub is_leaf: bool,
1462 pub expanded: bool,
1463}
1464
1465#[derive(Debug, Clone)]
1467pub struct TreeState {
1468 pub nodes: Vec<TreeNode>,
1470 pub selected: usize,
1472}
1473
1474impl TreeState {
1475 pub fn new(nodes: Vec<TreeNode>) -> Self {
1477 Self { nodes, selected: 0 }
1478 }
1479
1480 pub(crate) fn flatten(&self) -> Vec<FlatTreeEntry> {
1481 let mut entries = Vec::new();
1482 for node in &self.nodes {
1483 node.flatten(0, &mut entries);
1484 }
1485 entries
1486 }
1487
1488 pub(crate) fn toggle_at(&mut self, flat_index: usize) {
1489 let mut counter = 0usize;
1490 Self::toggle_recursive(&mut self.nodes, flat_index, &mut counter);
1491 }
1492
1493 fn toggle_recursive(nodes: &mut [TreeNode], target: usize, counter: &mut usize) -> bool {
1494 for node in nodes.iter_mut() {
1495 if *counter == target {
1496 if !node.is_leaf() {
1497 node.expanded = !node.expanded;
1498 }
1499 return true;
1500 }
1501 *counter += 1;
1502 if node.expanded && Self::toggle_recursive(&mut node.children, target, counter) {
1503 return true;
1504 }
1505 }
1506 false
1507 }
1508}
1509
1510#[derive(Debug, Clone)]
1514pub struct PaletteCommand {
1515 pub label: String,
1517 pub description: String,
1519 pub shortcut: Option<String>,
1521}
1522
1523impl PaletteCommand {
1524 pub fn new(label: impl Into<String>, description: impl Into<String>) -> Self {
1526 Self {
1527 label: label.into(),
1528 description: description.into(),
1529 shortcut: None,
1530 }
1531 }
1532
1533 pub fn shortcut(mut self, s: impl Into<String>) -> Self {
1535 self.shortcut = Some(s.into());
1536 self
1537 }
1538}
1539
1540#[derive(Debug, Clone)]
1544pub struct CommandPaletteState {
1545 pub commands: Vec<PaletteCommand>,
1547 pub input: String,
1549 pub cursor: usize,
1551 pub open: bool,
1553 pub last_selected: Option<usize>,
1556 selected: usize,
1557}
1558
1559impl CommandPaletteState {
1560 pub fn new(commands: Vec<PaletteCommand>) -> Self {
1562 Self {
1563 commands,
1564 input: String::new(),
1565 cursor: 0,
1566 open: false,
1567 last_selected: None,
1568 selected: 0,
1569 }
1570 }
1571
1572 pub fn toggle(&mut self) {
1574 self.open = !self.open;
1575 if self.open {
1576 self.input.clear();
1577 self.cursor = 0;
1578 self.selected = 0;
1579 }
1580 }
1581
1582 fn fuzzy_score(pattern: &str, text: &str) -> Option<i32> {
1583 let pattern = pattern.trim();
1584 if pattern.is_empty() {
1585 return Some(0);
1586 }
1587
1588 let text_chars: Vec<char> = text.chars().collect();
1589 let mut score = 0;
1590 let mut search_start = 0usize;
1591 let mut prev_match: Option<usize> = None;
1592
1593 for p in pattern.chars() {
1594 let mut found = None;
1595 for (idx, ch) in text_chars.iter().enumerate().skip(search_start) {
1596 if ch.eq_ignore_ascii_case(&p) {
1597 found = Some(idx);
1598 break;
1599 }
1600 }
1601
1602 let idx = found?;
1603 if prev_match.is_some_and(|prev| idx == prev + 1) {
1604 score += 3;
1605 } else {
1606 score += 1;
1607 }
1608
1609 if idx == 0 {
1610 score += 2;
1611 } else {
1612 let prev = text_chars[idx - 1];
1613 let curr = text_chars[idx];
1614 if matches!(prev, ' ' | '_' | '-') || prev.is_uppercase() || curr.is_uppercase() {
1615 score += 2;
1616 }
1617 }
1618
1619 prev_match = Some(idx);
1620 search_start = idx + 1;
1621 }
1622
1623 Some(score)
1624 }
1625
1626 pub(crate) fn filtered_indices(&self) -> Vec<usize> {
1627 let query = self.input.trim();
1628 if query.is_empty() {
1629 return (0..self.commands.len()).collect();
1630 }
1631
1632 let mut scored: Vec<(usize, i32)> = self
1633 .commands
1634 .iter()
1635 .enumerate()
1636 .filter_map(|(i, cmd)| {
1637 let mut haystack =
1638 String::with_capacity(cmd.label.len() + cmd.description.len() + 1);
1639 haystack.push_str(&cmd.label);
1640 haystack.push(' ');
1641 haystack.push_str(&cmd.description);
1642 Self::fuzzy_score(query, &haystack).map(|score| (i, score))
1643 })
1644 .collect();
1645
1646 if scored.is_empty() {
1647 let tokens: Vec<String> = query.split_whitespace().map(|t| t.to_lowercase()).collect();
1648 return self
1649 .commands
1650 .iter()
1651 .enumerate()
1652 .filter(|(_, cmd)| {
1653 let label = cmd.label.to_lowercase();
1654 let desc = cmd.description.to_lowercase();
1655 tokens.iter().all(|token| {
1656 label.contains(token.as_str()) || desc.contains(token.as_str())
1657 })
1658 })
1659 .map(|(i, _)| i)
1660 .collect();
1661 }
1662
1663 scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1664 scored.into_iter().map(|(idx, _)| idx).collect()
1665 }
1666
1667 pub(crate) fn selected(&self) -> usize {
1668 self.selected
1669 }
1670
1671 pub(crate) fn set_selected(&mut self, s: usize) {
1672 self.selected = s;
1673 }
1674}
1675
1676#[derive(Debug, Clone)]
1681pub struct StreamingTextState {
1682 pub content: String,
1684 pub streaming: bool,
1686 pub(crate) cursor_visible: bool,
1688 pub(crate) cursor_tick: u64,
1689}
1690
1691impl StreamingTextState {
1692 pub fn new() -> Self {
1694 Self {
1695 content: String::new(),
1696 streaming: false,
1697 cursor_visible: true,
1698 cursor_tick: 0,
1699 }
1700 }
1701
1702 pub fn push(&mut self, chunk: &str) {
1704 self.content.push_str(chunk);
1705 }
1706
1707 pub fn finish(&mut self) {
1709 self.streaming = false;
1710 }
1711
1712 pub fn start(&mut self) {
1714 self.content.clear();
1715 self.streaming = true;
1716 self.cursor_visible = true;
1717 self.cursor_tick = 0;
1718 }
1719
1720 pub fn clear(&mut self) {
1722 self.content.clear();
1723 self.streaming = false;
1724 self.cursor_visible = true;
1725 self.cursor_tick = 0;
1726 }
1727}
1728
1729impl Default for StreamingTextState {
1730 fn default() -> Self {
1731 Self::new()
1732 }
1733}
1734
1735#[derive(Debug, Clone)]
1740pub struct StreamingMarkdownState {
1741 pub content: String,
1743 pub streaming: bool,
1745 pub cursor_visible: bool,
1747 pub cursor_tick: u64,
1749 pub in_code_block: bool,
1751 pub code_block_lang: String,
1753}
1754
1755impl StreamingMarkdownState {
1756 pub fn new() -> Self {
1758 Self {
1759 content: String::new(),
1760 streaming: false,
1761 cursor_visible: true,
1762 cursor_tick: 0,
1763 in_code_block: false,
1764 code_block_lang: String::new(),
1765 }
1766 }
1767
1768 pub fn push(&mut self, chunk: &str) {
1770 self.content.push_str(chunk);
1771 }
1772
1773 pub fn start(&mut self) {
1775 self.content.clear();
1776 self.streaming = true;
1777 self.cursor_visible = true;
1778 self.cursor_tick = 0;
1779 self.in_code_block = false;
1780 self.code_block_lang.clear();
1781 }
1782
1783 pub fn finish(&mut self) {
1785 self.streaming = false;
1786 }
1787
1788 pub fn clear(&mut self) {
1790 self.content.clear();
1791 self.streaming = false;
1792 self.cursor_visible = true;
1793 self.cursor_tick = 0;
1794 self.in_code_block = false;
1795 self.code_block_lang.clear();
1796 }
1797}
1798
1799impl Default for StreamingMarkdownState {
1800 fn default() -> Self {
1801 Self::new()
1802 }
1803}
1804
1805#[derive(Debug, Clone)]
1810pub struct ScreenState {
1811 stack: Vec<String>,
1812}
1813
1814impl ScreenState {
1815 pub fn new(initial: impl Into<String>) -> Self {
1817 Self {
1818 stack: vec![initial.into()],
1819 }
1820 }
1821
1822 pub fn current(&self) -> &str {
1824 self.stack
1825 .last()
1826 .expect("ScreenState always contains at least one screen")
1827 .as_str()
1828 }
1829
1830 pub fn push(&mut self, name: impl Into<String>) {
1832 self.stack.push(name.into());
1833 }
1834
1835 pub fn pop(&mut self) {
1837 if self.can_pop() {
1838 self.stack.pop();
1839 }
1840 }
1841
1842 pub fn depth(&self) -> usize {
1844 self.stack.len()
1845 }
1846
1847 pub fn can_pop(&self) -> bool {
1849 self.stack.len() > 1
1850 }
1851
1852 pub fn reset(&mut self) {
1854 self.stack.truncate(1);
1855 }
1856}
1857
1858#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1860pub enum ApprovalAction {
1861 Pending,
1863 Approved,
1865 Rejected,
1867}
1868
1869#[derive(Debug, Clone)]
1875pub struct ToolApprovalState {
1876 pub tool_name: String,
1878 pub description: String,
1880 pub action: ApprovalAction,
1882}
1883
1884impl ToolApprovalState {
1885 pub fn new(tool_name: impl Into<String>, description: impl Into<String>) -> Self {
1887 Self {
1888 tool_name: tool_name.into(),
1889 description: description.into(),
1890 action: ApprovalAction::Pending,
1891 }
1892 }
1893
1894 pub fn reset(&mut self) {
1896 self.action = ApprovalAction::Pending;
1897 }
1898}
1899
1900#[derive(Debug, Clone)]
1902pub struct ContextItem {
1903 pub label: String,
1905 pub tokens: usize,
1907}
1908
1909impl ContextItem {
1910 pub fn new(label: impl Into<String>, tokens: usize) -> Self {
1912 Self {
1913 label: label.into(),
1914 tokens,
1915 }
1916 }
1917}
1918
1919#[cfg(test)]
1920mod tests {
1921 use super::*;
1922
1923 #[test]
1924 fn static_output_accumulates_and_drains_new_lines() {
1925 let mut output = StaticOutput::new();
1926 output.println("Building crate...");
1927 output.println("Compiling foo v0.1.0");
1928
1929 assert_eq!(
1930 output.lines(),
1931 &[
1932 "Building crate...".to_string(),
1933 "Compiling foo v0.1.0".to_string()
1934 ]
1935 );
1936
1937 let first = output.drain_new();
1938 assert_eq!(
1939 first,
1940 vec![
1941 "Building crate...".to_string(),
1942 "Compiling foo v0.1.0".to_string()
1943 ]
1944 );
1945 assert!(output.drain_new().is_empty());
1946
1947 output.println("Finished");
1948 assert_eq!(output.drain_new(), vec!["Finished".to_string()]);
1949 }
1950
1951 #[test]
1952 fn static_output_clear_resets_all_buffers() {
1953 let mut output = StaticOutput::new();
1954 output.println("line");
1955 output.clear();
1956
1957 assert!(output.lines().is_empty());
1958 assert!(output.drain_new().is_empty());
1959 }
1960
1961 #[test]
1962 fn form_field_default_values() {
1963 let field = FormField::default();
1964 assert_eq!(field.label, "");
1965 assert_eq!(field.input.value, "");
1966 assert_eq!(field.input.cursor, 0);
1967 assert_eq!(field.error, None);
1968 }
1969
1970 #[test]
1971 fn toast_message_default_values() {
1972 let msg = ToastMessage::default();
1973 assert_eq!(msg.text, "");
1974 assert!(matches!(msg.level, ToastLevel::Info));
1975 assert_eq!(msg.created_tick, 0);
1976 assert_eq!(msg.duration_ticks, 30);
1977 }
1978
1979 #[test]
1980 fn list_state_default_values() {
1981 let state = ListState::default();
1982 assert!(state.items.is_empty());
1983 assert_eq!(state.selected, 0);
1984 assert_eq!(state.filter, "");
1985 assert_eq!(state.visible_indices(), &[]);
1986 assert_eq!(state.selected_item(), None);
1987 }
1988
1989 #[test]
1990 fn file_entry_default_values() {
1991 let entry = FileEntry::default();
1992 assert_eq!(entry.name, "");
1993 assert_eq!(entry.path, PathBuf::new());
1994 assert!(!entry.is_dir);
1995 assert_eq!(entry.size, 0);
1996 }
1997
1998 #[test]
1999 fn tabs_state_default_values() {
2000 let state = TabsState::default();
2001 assert!(state.labels.is_empty());
2002 assert_eq!(state.selected, 0);
2003 assert_eq!(state.selected_label(), None);
2004 }
2005
2006 #[test]
2007 fn table_state_default_values() {
2008 let state = TableState::default();
2009 assert!(state.headers.is_empty());
2010 assert!(state.rows.is_empty());
2011 assert_eq!(state.selected, 0);
2012 assert_eq!(state.sort_column, None);
2013 assert!(state.sort_ascending);
2014 assert_eq!(state.filter, "");
2015 assert_eq!(state.page, 0);
2016 assert_eq!(state.page_size, 0);
2017 assert!(!state.zebra);
2018 assert_eq!(state.visible_indices(), &[]);
2019 }
2020
2021 #[test]
2022 fn select_state_default_values() {
2023 let state = SelectState::default();
2024 assert!(state.items.is_empty());
2025 assert_eq!(state.selected, 0);
2026 assert!(!state.open);
2027 assert_eq!(state.placeholder, "");
2028 assert_eq!(state.selected_item(), None);
2029 assert_eq!(state.cursor(), 0);
2030 }
2031
2032 #[test]
2033 fn radio_state_default_values() {
2034 let state = RadioState::default();
2035 assert!(state.items.is_empty());
2036 assert_eq!(state.selected, 0);
2037 assert_eq!(state.selected_item(), None);
2038 }
2039
2040 #[test]
2041 fn text_input_state_default_uses_new() {
2042 let state = TextInputState::default();
2043 assert_eq!(state.value, "");
2044 assert_eq!(state.cursor, 0);
2045 assert_eq!(state.placeholder, "");
2046 assert_eq!(state.max_length, None);
2047 assert_eq!(state.validation_error, None);
2048 assert!(!state.masked);
2049 }
2050
2051 #[test]
2052 fn tabs_state_new_sets_labels() {
2053 let state = TabsState::new(vec!["a", "b"]);
2054 assert_eq!(state.labels, vec!["a".to_string(), "b".to_string()]);
2055 assert_eq!(state.selected, 0);
2056 assert_eq!(state.selected_label(), Some("a"));
2057 }
2058
2059 #[test]
2060 fn list_state_new_selected_item_points_to_first_item() {
2061 let state = ListState::new(vec!["alpha", "beta"]);
2062 assert_eq!(state.items, vec!["alpha".to_string(), "beta".to_string()]);
2063 assert_eq!(state.selected, 0);
2064 assert_eq!(state.visible_indices(), &[0, 1]);
2065 assert_eq!(state.selected_item(), Some("alpha"));
2066 }
2067
2068 #[test]
2069 fn select_state_placeholder_builder_sets_value() {
2070 let state = SelectState::new(vec!["one", "two"]).placeholder("Pick one");
2071 assert_eq!(state.items, vec!["one".to_string(), "two".to_string()]);
2072 assert_eq!(state.placeholder, "Pick one");
2073 assert_eq!(state.selected_item(), Some("one"));
2074 }
2075
2076 #[test]
2077 fn radio_state_new_sets_items_and_selection() {
2078 let state = RadioState::new(vec!["red", "green"]);
2079 assert_eq!(state.items, vec!["red".to_string(), "green".to_string()]);
2080 assert_eq!(state.selected, 0);
2081 assert_eq!(state.selected_item(), Some("red"));
2082 }
2083
2084 #[test]
2085 fn table_state_new_sets_sort_ascending_true() {
2086 let state = TableState::new(vec!["Name"], vec![vec!["Alice"], vec!["Bob"]]);
2087 assert_eq!(state.headers, vec!["Name".to_string()]);
2088 assert_eq!(state.rows.len(), 2);
2089 assert!(state.sort_ascending);
2090 assert_eq!(state.sort_column, None);
2091 assert!(!state.zebra);
2092 assert_eq!(state.visible_indices(), &[0, 1]);
2093 }
2094
2095 #[test]
2096 fn command_palette_fuzzy_score_matches_gapped_pattern() {
2097 assert!(CommandPaletteState::fuzzy_score("sf", "Save File").is_some());
2098 assert!(CommandPaletteState::fuzzy_score("cmd", "Command Palette").is_some());
2099 assert_eq!(CommandPaletteState::fuzzy_score("xyz", "Save File"), None);
2100 }
2101
2102 #[test]
2103 fn command_palette_filtered_indices_uses_fuzzy_and_sorts() {
2104 let mut state = CommandPaletteState::new(vec![
2105 PaletteCommand::new("Save File", "Write buffer"),
2106 PaletteCommand::new("Search Files", "Find in workspace"),
2107 PaletteCommand::new("Quit", "Exit app"),
2108 ]);
2109
2110 state.input = "sf".to_string();
2111 let filtered = state.filtered_indices();
2112 assert_eq!(filtered, vec![0, 1]);
2113
2114 state.input = "buffer".to_string();
2115 let filtered = state.filtered_indices();
2116 assert_eq!(filtered, vec![0]);
2117 }
2118
2119 #[test]
2120 fn screen_state_push_pop_tracks_current_screen() {
2121 let mut screens = ScreenState::new("home");
2122 assert_eq!(screens.current(), "home");
2123 assert_eq!(screens.depth(), 1);
2124 assert!(!screens.can_pop());
2125
2126 screens.push("settings");
2127 assert_eq!(screens.current(), "settings");
2128 assert_eq!(screens.depth(), 2);
2129 assert!(screens.can_pop());
2130
2131 screens.push("profile");
2132 assert_eq!(screens.current(), "profile");
2133 assert_eq!(screens.depth(), 3);
2134
2135 screens.pop();
2136 assert_eq!(screens.current(), "settings");
2137 assert_eq!(screens.depth(), 2);
2138 }
2139
2140 #[test]
2141 fn screen_state_pop_never_removes_root() {
2142 let mut screens = ScreenState::new("home");
2143 screens.push("settings");
2144 screens.pop();
2145 screens.pop();
2146
2147 assert_eq!(screens.current(), "home");
2148 assert_eq!(screens.depth(), 1);
2149 assert!(!screens.can_pop());
2150 }
2151
2152 #[test]
2153 fn screen_state_reset_keeps_only_root() {
2154 let mut screens = ScreenState::new("home");
2155 screens.push("settings");
2156 screens.push("profile");
2157 assert_eq!(screens.current(), "profile");
2158
2159 screens.reset();
2160 assert_eq!(screens.current(), "home");
2161 assert_eq!(screens.depth(), 1);
2162 assert!(!screens.can_pop());
2163 }
2164
2165 #[test]
2166 fn calendar_days_in_month_handles_leap_years() {
2167 assert_eq!(CalendarState::days_in_month(2024, 2), 29);
2168 assert_eq!(CalendarState::days_in_month(2023, 2), 28);
2169 assert_eq!(CalendarState::days_in_month(2024, 1), 31);
2170 assert_eq!(CalendarState::days_in_month(2024, 4), 30);
2171 }
2172
2173 #[test]
2174 fn calendar_first_weekday_known_dates() {
2175 assert_eq!(CalendarState::first_weekday(2024, 1), 0);
2176 assert_eq!(CalendarState::first_weekday(2023, 10), 6);
2177 }
2178
2179 #[test]
2180 fn calendar_prev_next_month_handles_year_boundary() {
2181 let mut state = CalendarState::from_ym(2024, 12);
2182 state.prev_month();
2183 assert_eq!((state.year, state.month), (2024, 11));
2184
2185 let mut state = CalendarState::from_ym(2024, 1);
2186 state.prev_month();
2187 assert_eq!((state.year, state.month), (2023, 12));
2188
2189 state.next_month();
2190 assert_eq!((state.year, state.month), (2024, 1));
2191 }
2192}