1use ratatui::layout::Rect;
8
9use super::action_logger::ActionLog;
10use super::cell::CellPreview;
11
12#[derive(Debug, Clone)]
14pub enum DebugTableRow {
15 Section(String),
17 Entry { key: String, value: String },
19}
20
21#[derive(Debug, Clone)]
23pub struct DebugTableOverlay {
24 pub title: String,
26 pub rows: Vec<DebugTableRow>,
28 pub cell_preview: Option<CellPreview>,
30}
31
32impl DebugTableOverlay {
33 pub fn new(title: impl Into<String>, rows: Vec<DebugTableRow>) -> Self {
35 Self {
36 title: title.into(),
37 rows,
38 cell_preview: None,
39 }
40 }
41
42 pub fn with_cell_preview(
44 title: impl Into<String>,
45 rows: Vec<DebugTableRow>,
46 preview: CellPreview,
47 ) -> Self {
48 Self {
49 title: title.into(),
50 rows,
51 cell_preview: Some(preview),
52 }
53 }
54}
55
56#[derive(Debug, Clone)]
58pub enum DebugOverlay {
59 Inspect(DebugTableOverlay),
61 State(DebugTableOverlay),
63 ActionLog(ActionLogOverlay),
65 ActionDetail(ActionDetailOverlay),
67 Components(ComponentsOverlay),
69 StateDetail(StateEntryDetail),
71 ComponentDetail(ComponentDetailOverlay),
73}
74
75#[derive(Debug, Clone)]
77pub struct ActionDetailOverlay {
78 pub sequence: u64,
80 pub name: String,
82 pub params: String,
84 pub elapsed: String,
86}
87
88#[derive(Debug, Clone)]
90pub struct StateEntryDetail {
91 pub section: String,
93 pub key: String,
95 pub value: String,
97}
98
99#[derive(Debug, Clone)]
101pub struct ComponentDetailOverlay {
102 pub index: usize,
104 pub type_name: String,
106 pub type_name_full: String,
108 pub bound_id: Option<String>,
110 pub last_area: Option<Rect>,
112 pub debug_entries: Vec<(String, String)>,
114}
115
116impl ComponentDetailOverlay {
117 pub fn from_snapshot(snap: &ComponentSnapshot, index: usize) -> Self {
119 Self {
120 index,
121 type_name: snap.type_name.clone(),
122 type_name_full: snap.type_name_full.clone(),
123 bound_id: snap.bound_id.clone(),
124 last_area: snap.last_area,
125 debug_entries: snap.debug_entries.clone(),
126 }
127 }
128}
129
130#[derive(Debug, Clone)]
132pub struct ComponentSnapshot {
133 pub raw_id: u32,
135 pub type_name: String,
137 pub type_name_full: String,
139 pub bound_id: Option<String>,
141 pub last_area: Option<Rect>,
143 pub debug_entries: Vec<(String, String)>,
145}
146
147impl ComponentSnapshot {
148 pub fn from_mounted_info<Id: tui_dispatch_core::ComponentId>(
150 info: &tui_dispatch_components::MountedComponentInfo<Id>,
151 ) -> Self {
152 let type_name_full = info.type_name.to_string();
153 let type_name = type_name_full
154 .rsplit("::")
155 .next()
156 .unwrap_or(&type_name_full)
157 .to_string();
158 Self {
159 raw_id: info.raw,
160 type_name,
161 type_name_full,
162 bound_id: info.bound_id.map(|id| id.name().to_string()),
163 last_area: info.last_area,
164 debug_entries: info
165 .debug_state
166 .iter()
167 .map(|e| (e.key.clone(), e.value.clone()))
168 .collect(),
169 }
170 }
171}
172
173#[derive(Debug, Clone)]
175pub struct ComponentsOverlay {
176 pub title: String,
178 pub components: Vec<ComponentSnapshot>,
180 pub selected: usize,
182 pub expanded: std::collections::HashSet<usize>,
184}
185
186impl ComponentsOverlay {
187 pub fn new(title: impl Into<String>, components: Vec<ComponentSnapshot>) -> Self {
189 Self {
190 title: title.into(),
191 components,
192 selected: 0,
193 expanded: std::collections::HashSet::new(),
194 }
195 }
196
197 pub fn scroll_up(&mut self) {
199 self.selected = self.selected.saturating_sub(1);
200 }
201
202 pub fn scroll_down(&mut self) {
204 if !self.components.is_empty() {
205 self.selected = (self.selected + 1).min(self.components.len() - 1);
206 }
207 }
208
209 pub fn scroll_to_top(&mut self) {
211 self.selected = 0;
212 }
213
214 pub fn scroll_to_bottom(&mut self) {
216 if !self.components.is_empty() {
217 self.selected = self.components.len() - 1;
218 }
219 }
220
221 pub fn page_up(&mut self, page_size: usize) {
223 self.selected = self.selected.saturating_sub(page_size);
224 }
225
226 pub fn page_down(&mut self, page_size: usize) {
228 if !self.components.is_empty() {
229 self.selected = (self.selected + page_size).min(self.components.len() - 1);
230 }
231 }
232
233 pub fn toggle_expanded(&mut self) {
235 if !self.expanded.remove(&self.selected) {
236 self.expanded.insert(self.selected);
237 }
238 }
239
240 pub fn is_expanded(&self, index: usize) -> bool {
242 self.expanded.contains(&index)
243 }
244}
245
246impl DebugOverlay {
247 pub fn table(&self) -> Option<&DebugTableOverlay> {
249 match self {
250 DebugOverlay::Inspect(table) | DebugOverlay::State(table) => Some(table),
251 DebugOverlay::ActionLog(_)
252 | DebugOverlay::ActionDetail(_)
253 | DebugOverlay::Components(_)
254 | DebugOverlay::StateDetail(_)
255 | DebugOverlay::ComponentDetail(_) => None,
256 }
257 }
258
259 pub fn action_log(&self) -> Option<&ActionLogOverlay> {
261 match self {
262 DebugOverlay::ActionLog(log) => Some(log),
263 _ => None,
264 }
265 }
266
267 pub fn action_log_mut(&mut self) -> Option<&mut ActionLogOverlay> {
269 match self {
270 DebugOverlay::ActionLog(log) => Some(log),
271 _ => None,
272 }
273 }
274
275 pub fn components(&self) -> Option<&ComponentsOverlay> {
277 match self {
278 DebugOverlay::Components(c) => Some(c),
279 _ => None,
280 }
281 }
282
283 pub fn components_mut(&mut self) -> Option<&mut ComponentsOverlay> {
285 match self {
286 DebugOverlay::Components(c) => Some(c),
287 _ => None,
288 }
289 }
290
291 pub fn kind(&self) -> &'static str {
293 match self {
294 DebugOverlay::Inspect(_) => "inspect",
295 DebugOverlay::State(_) => "state",
296 DebugOverlay::ActionLog(_) => "action_log",
297 DebugOverlay::ActionDetail(_) => "action_detail",
298 DebugOverlay::Components(_) => "components",
299 DebugOverlay::StateDetail(_) => "state_detail",
300 DebugOverlay::ComponentDetail(_) => "component_detail",
301 }
302 }
303}
304
305#[derive(Debug, Clone)]
311pub struct ActionLogDisplayEntry {
312 pub sequence: u64,
314 pub name: String,
316 pub params: String,
318 pub params_detail: String,
320 pub elapsed: String,
322}
323
324#[derive(Debug, Clone)]
326pub struct ActionLogOverlay {
327 pub title: String,
329 pub entries: Vec<ActionLogDisplayEntry>,
331 pub selected: usize,
333 pub scroll_offset: usize,
335 pub search_query: String,
337 pub search_matches: Vec<usize>,
339 pub search_match_index: usize,
341 pub search_input_active: bool,
343}
344
345impl ActionLogOverlay {
346 pub fn from_log(log: &ActionLog, title: impl Into<String>) -> Self {
348 let entries: Vec<_> = log
349 .entries_rev()
350 .map(|e| ActionLogDisplayEntry {
351 sequence: e.sequence,
352 name: e.name.to_string(),
353 params: e.params.clone(),
354 params_detail: e.params_pretty.clone(),
355 elapsed: e.elapsed.clone(),
356 })
357 .collect();
358
359 Self {
360 title: title.into(),
361 entries,
362 selected: 0,
363 scroll_offset: 0,
364 search_query: String::new(),
365 search_matches: Vec::new(),
366 search_match_index: 0,
367 search_input_active: false,
368 }
369 }
370
371 pub fn scroll_up(&mut self) {
373 if self.navigate_filtered(|current, _| current.saturating_sub(1)) {
374 return;
375 }
376 if self.selected > 0 {
377 self.selected -= 1;
378 self.sync_search_index_from_selection();
379 }
380 }
381
382 pub fn scroll_down(&mut self) {
384 if self.navigate_filtered(|current, max| current.saturating_add(1).min(max)) {
385 return;
386 }
387 if self.selected + 1 < self.entries.len() {
388 self.selected += 1;
389 self.sync_search_index_from_selection();
390 }
391 }
392
393 pub fn scroll_to_top(&mut self) {
395 if self.navigate_filtered(|_, _| 0) {
396 return;
397 }
398 self.selected = 0;
399 self.sync_search_index_from_selection();
400 }
401
402 pub fn scroll_to_bottom(&mut self) {
404 if self.navigate_filtered(|_, max| max) {
405 return;
406 }
407 if !self.entries.is_empty() {
408 self.selected = self.entries.len() - 1;
409 self.sync_search_index_from_selection();
410 }
411 }
412
413 pub fn page_up(&mut self, page_size: usize) {
415 if self.navigate_filtered(|current, _| current.saturating_sub(page_size)) {
416 return;
417 }
418 self.selected = self.selected.saturating_sub(page_size);
419 self.sync_search_index_from_selection();
420 }
421
422 pub fn page_down(&mut self, page_size: usize) {
424 if self.navigate_filtered(|current, max| current.saturating_add(page_size).min(max)) {
425 return;
426 }
427 self.selected = (self.selected + page_size).min(self.entries.len().saturating_sub(1));
428 self.sync_search_index_from_selection();
429 }
430
431 pub fn scroll_offset_for(&self, visible_rows: usize) -> usize {
433 if visible_rows == 0 {
434 return 0;
435 }
436 if self.selected >= visible_rows {
437 self.selected - visible_rows + 1
438 } else {
439 0
440 }
441 }
442
443 pub fn get_selected(&self) -> Option<&ActionLogDisplayEntry> {
445 self.entries.get(self.selected)
446 }
447
448 pub fn selected_detail(&self) -> Option<ActionDetailOverlay> {
450 self.get_selected().map(|entry| ActionDetailOverlay {
451 sequence: entry.sequence,
452 name: entry.name.clone(),
453 params: entry.params_detail.clone(),
454 elapsed: entry.elapsed.clone(),
455 })
456 }
457
458 pub fn set_search_query(&mut self, query: impl Into<String>) {
460 self.search_query = query.into();
461 self.rebuild_search_matches();
462 }
463
464 pub fn push_search_char(&mut self, ch: char) {
466 self.search_query.push(ch);
467 self.rebuild_search_matches();
468 }
469
470 pub fn pop_search_char(&mut self) -> bool {
472 let popped = self.search_query.pop().is_some();
473 if popped {
474 self.rebuild_search_matches();
475 }
476 popped
477 }
478
479 pub fn clear_search_query(&mut self) {
481 self.search_query.clear();
482 self.search_matches.clear();
483 self.search_match_index = 0;
484 }
485
486 pub fn search_next(&mut self) -> bool {
488 if self.search_matches.is_empty() {
489 return false;
490 }
491 self.search_match_index = (self.search_match_index + 1) % self.search_matches.len();
492 self.selected = self.search_matches[self.search_match_index];
493 true
494 }
495
496 pub fn search_prev(&mut self) -> bool {
498 if self.search_matches.is_empty() {
499 return false;
500 }
501 self.search_match_index = if self.search_match_index == 0 {
502 self.search_matches.len() - 1
503 } else {
504 self.search_match_index - 1
505 };
506 self.selected = self.search_matches[self.search_match_index];
507 true
508 }
509
510 pub fn has_search_query(&self) -> bool {
512 !self.search_query.is_empty()
513 }
514
515 fn navigate_filtered<F>(&mut self, advance: F) -> bool
518 where
519 F: FnOnce(usize, usize) -> usize,
520 {
521 if !self.has_search_query() {
522 return false;
523 }
524 if self.search_matches.is_empty() {
525 return true;
527 }
528
529 let max_match_index = self.search_matches.len() - 1;
530 self.search_match_index =
531 advance(self.search_match_index, max_match_index).min(max_match_index);
532 self.selected = self.search_matches[self.search_match_index];
533 true
534 }
535
536 pub fn search_match_count(&self) -> usize {
538 self.search_matches.len()
539 }
540
541 pub fn search_match_position(&self) -> Option<(usize, usize)> {
543 if self.search_matches.is_empty() {
544 None
545 } else {
546 Some((self.search_match_index + 1, self.search_matches.len()))
547 }
548 }
549
550 pub fn is_search_match(&self, row_index: usize) -> bool {
552 self.search_matches.binary_search(&row_index).is_ok()
553 }
554
555 fn rebuild_search_matches(&mut self) {
556 self.search_matches.clear();
557 self.search_match_index = 0;
558
559 let query = self.search_query.trim().to_ascii_lowercase();
560 if query.is_empty() {
561 return;
562 }
563
564 for (idx, entry) in self.entries.iter().enumerate() {
565 let name = entry.name.to_ascii_lowercase();
566 let params = entry.params.to_ascii_lowercase();
567 let params_detail = entry.params_detail.to_ascii_lowercase();
568 if name.contains(&query) || params.contains(&query) || params_detail.contains(&query) {
569 self.search_matches.push(idx);
571 }
572 }
573
574 if self.search_matches.is_empty() {
575 return;
576 }
577
578 if let Some(position) = self
579 .search_matches
580 .iter()
581 .position(|&idx| idx == self.selected)
582 {
583 self.search_match_index = position;
584 } else {
585 self.search_match_index = 0;
586 self.selected = self.search_matches[0];
587 }
588 }
589
590 fn sync_search_index_from_selection(&mut self) {
591 if self.search_matches.is_empty() {
592 return;
593 }
594 if let Some(position) = self
595 .search_matches
596 .iter()
597 .position(|&idx| idx == self.selected)
598 {
599 self.search_match_index = position;
600 } else {
601 self.search_match_index = self.search_match_index.min(self.search_matches.len() - 1);
602 self.selected = self.search_matches[self.search_match_index];
603 }
604 }
605}
606
607#[derive(Debug, Default)]
626pub struct DebugTableBuilder {
627 rows: Vec<DebugTableRow>,
628 cell_preview: Option<CellPreview>,
629}
630
631impl DebugTableBuilder {
632 pub fn new() -> Self {
634 Self::default()
635 }
636
637 pub fn section(mut self, title: impl Into<String>) -> Self {
639 self.rows.push(DebugTableRow::Section(title.into()));
640 self
641 }
642
643 pub fn entry(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
645 self.rows.push(DebugTableRow::Entry {
646 key: key.into(),
647 value: value.into(),
648 });
649 self
650 }
651
652 pub fn push_section(&mut self, title: impl Into<String>) {
654 self.rows.push(DebugTableRow::Section(title.into()));
655 }
656
657 pub fn push_entry(&mut self, key: impl Into<String>, value: impl Into<String>) {
659 self.rows.push(DebugTableRow::Entry {
660 key: key.into(),
661 value: value.into(),
662 });
663 }
664
665 pub fn cell_preview(mut self, preview: CellPreview) -> Self {
667 self.cell_preview = Some(preview);
668 self
669 }
670
671 pub fn set_cell_preview(&mut self, preview: CellPreview) {
673 self.cell_preview = Some(preview);
674 }
675
676 pub fn finish(self, title: impl Into<String>) -> DebugTableOverlay {
678 DebugTableOverlay {
679 title: title.into(),
680 rows: self.rows,
681 cell_preview: self.cell_preview,
682 }
683 }
684
685 pub fn finish_inspect(self, title: impl Into<String>) -> DebugOverlay {
687 DebugOverlay::Inspect(self.finish(title))
688 }
689
690 pub fn finish_state(self, title: impl Into<String>) -> DebugOverlay {
692 DebugOverlay::State(self.finish(title))
693 }
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699
700 #[test]
701 fn test_builder_basic() {
702 let table = DebugTableBuilder::new()
703 .section("Test")
704 .entry("key1", "value1")
705 .entry("key2", "value2")
706 .finish("Test Table");
707
708 assert_eq!(table.title, "Test Table");
709 assert_eq!(table.rows.len(), 3);
710 assert!(table.cell_preview.is_none());
711 }
712
713 #[test]
714 fn test_builder_multiple_sections() {
715 let table = DebugTableBuilder::new()
716 .section("Section 1")
717 .entry("a", "1")
718 .section("Section 2")
719 .entry("b", "2")
720 .finish("Multi-Section");
721
722 assert_eq!(table.rows.len(), 4);
723
724 match &table.rows[0] {
725 DebugTableRow::Section(s) => assert_eq!(s, "Section 1"),
726 _ => panic!("Expected section"),
727 }
728 match &table.rows[2] {
729 DebugTableRow::Section(s) => assert_eq!(s, "Section 2"),
730 _ => panic!("Expected section"),
731 }
732 }
733
734 #[test]
735 fn test_overlay_kinds() {
736 let table = DebugTableBuilder::new().finish("Test");
737
738 let inspect = DebugOverlay::Inspect(table.clone());
739 assert_eq!(inspect.kind(), "inspect");
740 assert!(inspect.table().is_some());
741 assert!(inspect.action_log().is_none());
742
743 let state = DebugOverlay::State(table);
744 assert_eq!(state.kind(), "state");
745
746 let action_log = ActionLogOverlay {
747 title: "Test".to_string(),
748 entries: vec![],
749 selected: 0,
750 scroll_offset: 0,
751 search_query: String::new(),
752 search_matches: vec![],
753 search_match_index: 0,
754 search_input_active: false,
755 };
756 let log_overlay = DebugOverlay::ActionLog(action_log);
757 assert_eq!(log_overlay.kind(), "action_log");
758 assert!(log_overlay.table().is_none());
759 assert!(log_overlay.action_log().is_some());
760 }
761
762 #[test]
763 fn test_action_log_overlay_scrolling() {
764 let mut overlay = ActionLogOverlay {
765 title: "Test".to_string(),
766 entries: vec![
767 ActionLogDisplayEntry {
768 sequence: 0,
769 name: "A".to_string(),
770 params: "".to_string(),
771 params_detail: "".to_string(),
772 elapsed: "0ms".to_string(),
773 },
774 ActionLogDisplayEntry {
775 sequence: 1,
776 name: "B".to_string(),
777 params: "x: 1".to_string(),
778 params_detail: "x: 1".to_string(),
779 elapsed: "1ms".to_string(),
780 },
781 ActionLogDisplayEntry {
782 sequence: 2,
783 name: "C".to_string(),
784 params: "y: 2".to_string(),
785 params_detail: "y: 2".to_string(),
786 elapsed: "2ms".to_string(),
787 },
788 ],
789 selected: 0,
790 scroll_offset: 0,
791 search_query: String::new(),
792 search_matches: vec![],
793 search_match_index: 0,
794 search_input_active: false,
795 };
796
797 assert_eq!(overlay.selected, 0);
798
799 overlay.scroll_down();
800 assert_eq!(overlay.selected, 1);
801
802 overlay.scroll_down();
803 assert_eq!(overlay.selected, 2);
804
805 overlay.scroll_down(); assert_eq!(overlay.selected, 2);
807
808 overlay.scroll_up();
809 assert_eq!(overlay.selected, 1);
810
811 overlay.scroll_to_top();
812 assert_eq!(overlay.selected, 0);
813
814 overlay.scroll_to_bottom();
815 assert_eq!(overlay.selected, 2);
816 }
817
818 #[test]
819 fn test_action_log_overlay_search_query_and_navigation() {
820 let mut overlay = ActionLogOverlay {
821 title: "Test".to_string(),
822 entries: vec![
823 ActionLogDisplayEntry {
824 sequence: 10,
825 name: "SearchStart".to_string(),
826 params: "query: \"foo\"".to_string(),
827 params_detail: "query: \"foo\"".to_string(),
828 elapsed: "0ms".to_string(),
829 },
830 ActionLogDisplayEntry {
831 sequence: 11,
832 name: "SearchSubmit".to_string(),
833 params: "query: \"foo\"".to_string(),
834 params_detail: "query: \"foo\"".to_string(),
835 elapsed: "1ms".to_string(),
836 },
837 ActionLogDisplayEntry {
838 sequence: 12,
839 name: "Connect".to_string(),
840 params: "host: \"localhost\"".to_string(),
841 params_detail: "host: \"localhost\"".to_string(),
842 elapsed: "2ms".to_string(),
843 },
844 ],
845 selected: 0,
846 scroll_offset: 0,
847 search_query: String::new(),
848 search_matches: vec![],
849 search_match_index: 0,
850 search_input_active: false,
851 };
852
853 overlay.set_search_query("search");
854 assert!(overlay.has_search_query());
855 assert_eq!(overlay.search_match_count(), 2);
856 assert_eq!(overlay.selected, 0);
857 assert_eq!(overlay.search_match_position(), Some((1, 2)));
858
859 assert!(overlay.search_next());
860 assert_eq!(overlay.selected, 1);
861 assert_eq!(overlay.search_match_position(), Some((2, 2)));
862
863 assert!(overlay.search_next());
864 assert_eq!(overlay.selected, 0);
865 assert_eq!(overlay.search_match_position(), Some((1, 2)));
866
867 assert!(overlay.search_prev());
868 assert_eq!(overlay.selected, 1);
869 assert_eq!(overlay.search_match_position(), Some((2, 2)));
870 assert_eq!(overlay.search_matches, vec![0, 1]);
871 }
872
873 #[test]
874 fn test_action_log_overlay_search_edge_cases() {
875 let mut overlay = ActionLogOverlay {
876 title: "Test".to_string(),
877 entries: vec![ActionLogDisplayEntry {
878 sequence: 0,
879 name: "Connect".to_string(),
880 params: "host: \"example\"".to_string(),
881 params_detail: "host: \"example\"".to_string(),
882 elapsed: "0ms".to_string(),
883 }],
884 selected: 0,
885 scroll_offset: 0,
886 search_query: String::new(),
887 search_matches: vec![],
888 search_match_index: 0,
889 search_input_active: false,
890 };
891
892 overlay.set_search_query("missing");
893 assert_eq!(overlay.search_match_count(), 0);
894 assert!(!overlay.search_next());
895 assert!(!overlay.search_prev());
896 assert_eq!(overlay.search_match_position(), None);
897
898 overlay.set_search_query("connect");
899 assert_eq!(overlay.search_match_count(), 1);
900 assert_eq!(overlay.search_match_position(), Some((1, 1)));
901 assert!(overlay.search_next());
902 assert_eq!(overlay.search_match_position(), Some((1, 1)));
903
904 assert!(overlay.pop_search_char());
905 assert!(overlay.has_search_query());
906 overlay.clear_search_query();
907 assert!(!overlay.has_search_query());
908 assert_eq!(overlay.search_match_count(), 0);
909 }
910
911 #[test]
912 fn test_action_log_overlay_scroll_respects_filter() {
913 let mut overlay = ActionLogOverlay {
914 title: "Test".to_string(),
915 entries: vec![
916 ActionLogDisplayEntry {
917 sequence: 0,
918 name: "SearchStart".to_string(),
919 params: "".to_string(),
920 params_detail: "".to_string(),
921 elapsed: "0ms".to_string(),
922 },
923 ActionLogDisplayEntry {
924 sequence: 1,
925 name: "Connect".to_string(),
926 params: "".to_string(),
927 params_detail: "".to_string(),
928 elapsed: "1ms".to_string(),
929 },
930 ActionLogDisplayEntry {
931 sequence: 2,
932 name: "SearchSubmit".to_string(),
933 params: "".to_string(),
934 params_detail: "".to_string(),
935 elapsed: "2ms".to_string(),
936 },
937 ],
938 selected: 0,
939 scroll_offset: 0,
940 search_query: String::new(),
941 search_matches: vec![],
942 search_match_index: 0,
943 search_input_active: false,
944 };
945
946 overlay.set_search_query("search");
947 assert_eq!(overlay.search_matches, vec![0, 2]);
948 assert_eq!(overlay.selected, 0);
949
950 overlay.scroll_down();
951 assert_eq!(overlay.selected, 2);
952 overlay.scroll_up();
953 assert_eq!(overlay.selected, 0);
954 }
955}