Skip to main content

tui_dispatch_debug/debug/
table.rs

1//! Debug table types and builder
2//!
3//! Provides a builder pattern for constructing debug information tables
4//! with sections and key-value entries. Also includes action log overlay
5//! for displaying recent actions.
6
7use super::action_logger::ActionLog;
8use super::cell::CellPreview;
9
10/// A row in a debug table - either a section header or a key-value entry
11#[derive(Debug, Clone)]
12pub enum DebugTableRow {
13    /// Section header (e.g., "Connection", "Keys", "UI")
14    Section(String),
15    /// Key-value entry (e.g., "host" -> "localhost")
16    Entry { key: String, value: String },
17}
18
19/// A debug table overlay with title, rows, and optional cell preview
20#[derive(Debug, Clone)]
21pub struct DebugTableOverlay {
22    /// Title displayed at the top of the overlay
23    pub title: String,
24    /// Table rows (sections and entries)
25    pub rows: Vec<DebugTableRow>,
26    /// Optional cell preview for inspect overlays
27    pub cell_preview: Option<CellPreview>,
28}
29
30impl DebugTableOverlay {
31    /// Create a new overlay with the given title and rows
32    pub fn new(title: impl Into<String>, rows: Vec<DebugTableRow>) -> Self {
33        Self {
34            title: title.into(),
35            rows,
36            cell_preview: None,
37        }
38    }
39
40    /// Create a new overlay with cell preview
41    pub fn with_cell_preview(
42        title: impl Into<String>,
43        rows: Vec<DebugTableRow>,
44        preview: CellPreview,
45    ) -> Self {
46        Self {
47            title: title.into(),
48            rows,
49            cell_preview: Some(preview),
50        }
51    }
52}
53
54/// Type of debug overlay
55#[derive(Debug, Clone)]
56pub enum DebugOverlay {
57    /// Inspect overlay - shows info about a specific position/cell
58    Inspect(DebugTableOverlay),
59    /// State overlay - shows full application state
60    State(DebugTableOverlay),
61    /// Action log overlay - shows recent actions with timestamps
62    ActionLog(ActionLogOverlay),
63    /// Action detail overlay - shows full details of a single action
64    ActionDetail(ActionDetailOverlay),
65}
66
67/// Overlay for displaying detailed action information
68#[derive(Debug, Clone)]
69pub struct ActionDetailOverlay {
70    /// Sequence number
71    pub sequence: u64,
72    /// Action name
73    pub name: String,
74    /// Full action parameters
75    pub params: String,
76    /// Elapsed time display
77    pub elapsed: String,
78}
79
80impl DebugOverlay {
81    /// Get the underlying table from the overlay (for Table/State/Inspect)
82    pub fn table(&self) -> Option<&DebugTableOverlay> {
83        match self {
84            DebugOverlay::Inspect(table) | DebugOverlay::State(table) => Some(table),
85            DebugOverlay::ActionLog(_) | DebugOverlay::ActionDetail(_) => None,
86        }
87    }
88
89    /// Get the action log overlay
90    pub fn action_log(&self) -> Option<&ActionLogOverlay> {
91        match self {
92            DebugOverlay::ActionLog(log) => Some(log),
93            _ => None,
94        }
95    }
96
97    /// Get the action log overlay mutably
98    pub fn action_log_mut(&mut self) -> Option<&mut ActionLogOverlay> {
99        match self {
100            DebugOverlay::ActionLog(log) => Some(log),
101            _ => None,
102        }
103    }
104
105    /// Get the overlay kind as a string
106    pub fn kind(&self) -> &'static str {
107        match self {
108            DebugOverlay::Inspect(_) => "inspect",
109            DebugOverlay::State(_) => "state",
110            DebugOverlay::ActionLog(_) => "action_log",
111            DebugOverlay::ActionDetail(_) => "action_detail",
112        }
113    }
114}
115
116// ============================================================================
117// Action Log Overlay
118// ============================================================================
119
120/// A display-ready action log entry
121#[derive(Debug, Clone)]
122pub struct ActionLogDisplayEntry {
123    /// Sequence number
124    pub sequence: u64,
125    /// Action name
126    pub name: String,
127    /// Action parameters (without the action name)
128    pub params: String,
129    /// Pretty action parameters for detail view
130    pub params_detail: String,
131    /// Elapsed time display (e.g., "2.3s")
132    pub elapsed: String,
133}
134
135/// Overlay for displaying the action log
136#[derive(Debug, Clone)]
137pub struct ActionLogOverlay {
138    /// Title for the overlay
139    pub title: String,
140    /// Action entries to display
141    pub entries: Vec<ActionLogDisplayEntry>,
142    /// Currently selected entry index (for scrolling)
143    pub selected: usize,
144    /// Scroll offset for visible window
145    pub scroll_offset: usize,
146    /// Active action-log search query
147    pub search_query: String,
148    /// Matched row indices for the current search query
149    pub search_matches: Vec<usize>,
150    /// Current position within `search_matches`
151    pub search_match_index: usize,
152    /// Whether search input mode is active
153    pub search_input_active: bool,
154}
155
156impl ActionLogOverlay {
157    /// Create from an ActionLog reference
158    pub fn from_log(log: &ActionLog, title: impl Into<String>) -> Self {
159        let entries: Vec<_> = log
160            .entries_rev()
161            .map(|e| ActionLogDisplayEntry {
162                sequence: e.sequence,
163                name: e.name.to_string(),
164                params: e.params.clone(),
165                params_detail: e.params_pretty.clone(),
166                elapsed: e.elapsed.clone(),
167            })
168            .collect();
169
170        Self {
171            title: title.into(),
172            entries,
173            selected: 0,
174            scroll_offset: 0,
175            search_query: String::new(),
176            search_matches: Vec::new(),
177            search_match_index: 0,
178            search_input_active: false,
179        }
180    }
181
182    /// Scroll up (select previous entry)
183    pub fn scroll_up(&mut self) {
184        if self.navigate_filtered(|current, _| current.saturating_sub(1)) {
185            return;
186        }
187        if self.selected > 0 {
188            self.selected -= 1;
189            self.sync_search_index_from_selection();
190        }
191    }
192
193    /// Scroll down (select next entry)
194    pub fn scroll_down(&mut self) {
195        if self.navigate_filtered(|current, max| current.saturating_add(1).min(max)) {
196            return;
197        }
198        if self.selected + 1 < self.entries.len() {
199            self.selected += 1;
200            self.sync_search_index_from_selection();
201        }
202    }
203
204    /// Jump to the top
205    pub fn scroll_to_top(&mut self) {
206        if self.navigate_filtered(|_, _| 0) {
207            return;
208        }
209        self.selected = 0;
210        self.sync_search_index_from_selection();
211    }
212
213    /// Jump to the bottom
214    pub fn scroll_to_bottom(&mut self) {
215        if self.navigate_filtered(|_, max| max) {
216            return;
217        }
218        if !self.entries.is_empty() {
219            self.selected = self.entries.len() - 1;
220            self.sync_search_index_from_selection();
221        }
222    }
223
224    /// Page up
225    pub fn page_up(&mut self, page_size: usize) {
226        if self.navigate_filtered(|current, _| current.saturating_sub(page_size)) {
227            return;
228        }
229        self.selected = self.selected.saturating_sub(page_size);
230        self.sync_search_index_from_selection();
231    }
232
233    /// Page down
234    pub fn page_down(&mut self, page_size: usize) {
235        if self.navigate_filtered(|current, max| current.saturating_add(page_size).min(max)) {
236            return;
237        }
238        self.selected = (self.selected + page_size).min(self.entries.len().saturating_sub(1));
239        self.sync_search_index_from_selection();
240    }
241
242    /// Calculate the scroll offset to keep the selected row visible.
243    pub fn scroll_offset_for(&self, visible_rows: usize) -> usize {
244        if visible_rows == 0 {
245            return 0;
246        }
247        if self.selected >= visible_rows {
248            self.selected - visible_rows + 1
249        } else {
250            0
251        }
252    }
253
254    /// Get the currently selected entry
255    pub fn get_selected(&self) -> Option<&ActionLogDisplayEntry> {
256        self.entries.get(self.selected)
257    }
258
259    /// Create a detail overlay from the selected entry
260    pub fn selected_detail(&self) -> Option<ActionDetailOverlay> {
261        self.get_selected().map(|entry| ActionDetailOverlay {
262            sequence: entry.sequence,
263            name: entry.name.clone(),
264            params: entry.params_detail.clone(),
265            elapsed: entry.elapsed.clone(),
266        })
267    }
268
269    /// Set the current search query and recompute matching rows.
270    pub fn set_search_query(&mut self, query: impl Into<String>) {
271        self.search_query = query.into();
272        self.rebuild_search_matches();
273    }
274
275    /// Append a character to the active search query.
276    pub fn push_search_char(&mut self, ch: char) {
277        self.search_query.push(ch);
278        self.rebuild_search_matches();
279    }
280
281    /// Pop the last search query character.
282    pub fn pop_search_char(&mut self) -> bool {
283        let popped = self.search_query.pop().is_some();
284        if popped {
285            self.rebuild_search_matches();
286        }
287        popped
288    }
289
290    /// Clear the active search query and matches.
291    pub fn clear_search_query(&mut self) {
292        self.search_query.clear();
293        self.search_matches.clear();
294        self.search_match_index = 0;
295    }
296
297    /// Move to the next match, wrapping at the end.
298    pub fn search_next(&mut self) -> bool {
299        if self.search_matches.is_empty() {
300            return false;
301        }
302        self.search_match_index = (self.search_match_index + 1) % self.search_matches.len();
303        self.selected = self.search_matches[self.search_match_index];
304        true
305    }
306
307    /// Move to the previous match, wrapping at the beginning.
308    pub fn search_prev(&mut self) -> bool {
309        if self.search_matches.is_empty() {
310            return false;
311        }
312        self.search_match_index = if self.search_match_index == 0 {
313            self.search_matches.len() - 1
314        } else {
315            self.search_match_index - 1
316        };
317        self.selected = self.search_matches[self.search_match_index];
318        true
319    }
320
321    /// Returns true when a search query is active.
322    pub fn has_search_query(&self) -> bool {
323        !self.search_query.is_empty()
324    }
325
326    // Returns `true` when filtered mode handled navigation, so callers should
327    // skip unfiltered selection movement for this key event.
328    fn navigate_filtered<F>(&mut self, advance: F) -> bool
329    where
330        F: FnOnce(usize, usize) -> usize,
331    {
332        if !self.has_search_query() {
333            return false;
334        }
335        if self.search_matches.is_empty() {
336            // Keep navigation scoped to filtered rows while a query is active.
337            return true;
338        }
339
340        let max_match_index = self.search_matches.len() - 1;
341        self.search_match_index =
342            advance(self.search_match_index, max_match_index).min(max_match_index);
343        self.selected = self.search_matches[self.search_match_index];
344        true
345    }
346
347    /// Number of matching rows for the active query.
348    pub fn search_match_count(&self) -> usize {
349        self.search_matches.len()
350    }
351
352    /// Current search match position as `(index, total)` (1-based index).
353    pub fn search_match_position(&self) -> Option<(usize, usize)> {
354        if self.search_matches.is_empty() {
355            None
356        } else {
357            Some((self.search_match_index + 1, self.search_matches.len()))
358        }
359    }
360
361    /// Returns true if this row index is currently matched by the query.
362    pub fn is_search_match(&self, row_index: usize) -> bool {
363        self.search_matches.binary_search(&row_index).is_ok()
364    }
365
366    fn rebuild_search_matches(&mut self) {
367        self.search_matches.clear();
368        self.search_match_index = 0;
369
370        let query = self.search_query.trim().to_ascii_lowercase();
371        if query.is_empty() {
372            return;
373        }
374
375        for (idx, entry) in self.entries.iter().enumerate() {
376            let name = entry.name.to_ascii_lowercase();
377            let params = entry.params.to_ascii_lowercase();
378            let params_detail = entry.params_detail.to_ascii_lowercase();
379            if name.contains(&query) || params.contains(&query) || params_detail.contains(&query) {
380                // Preserves ascending order so membership checks can use binary search.
381                self.search_matches.push(idx);
382            }
383        }
384
385        if self.search_matches.is_empty() {
386            return;
387        }
388
389        if let Some(position) = self
390            .search_matches
391            .iter()
392            .position(|&idx| idx == self.selected)
393        {
394            self.search_match_index = position;
395        } else {
396            self.search_match_index = 0;
397            self.selected = self.search_matches[0];
398        }
399    }
400
401    fn sync_search_index_from_selection(&mut self) {
402        if self.search_matches.is_empty() {
403            return;
404        }
405        if let Some(position) = self
406            .search_matches
407            .iter()
408            .position(|&idx| idx == self.selected)
409        {
410            self.search_match_index = position;
411        } else {
412            self.search_match_index = self.search_match_index.min(self.search_matches.len() - 1);
413            self.selected = self.search_matches[self.search_match_index];
414        }
415    }
416}
417
418/// Builder for constructing debug tables
419///
420/// # Example
421///
422/// ```
423/// use tui_dispatch_debug::debug::{DebugTableBuilder, DebugTableRow};
424///
425/// let table = DebugTableBuilder::new()
426///     .section("Connection")
427///     .entry("host", "localhost")
428///     .entry("port", "6379")
429///     .section("Status")
430///     .entry("connected", "true")
431///     .finish("Connection Info");
432///
433/// assert_eq!(table.title, "Connection Info");
434/// assert_eq!(table.rows.len(), 5);
435/// ```
436#[derive(Debug, Default)]
437pub struct DebugTableBuilder {
438    rows: Vec<DebugTableRow>,
439    cell_preview: Option<CellPreview>,
440}
441
442impl DebugTableBuilder {
443    /// Create a new empty builder
444    pub fn new() -> Self {
445        Self::default()
446    }
447
448    /// Add a section header
449    pub fn section(mut self, title: impl Into<String>) -> Self {
450        self.rows.push(DebugTableRow::Section(title.into()));
451        self
452    }
453
454    /// Add a key-value entry
455    pub fn entry(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
456        self.rows.push(DebugTableRow::Entry {
457            key: key.into(),
458            value: value.into(),
459        });
460        self
461    }
462
463    /// Add a section header (mutable reference version)
464    pub fn push_section(&mut self, title: impl Into<String>) {
465        self.rows.push(DebugTableRow::Section(title.into()));
466    }
467
468    /// Add a key-value entry (mutable reference version)
469    pub fn push_entry(&mut self, key: impl Into<String>, value: impl Into<String>) {
470        self.rows.push(DebugTableRow::Entry {
471            key: key.into(),
472            value: value.into(),
473        });
474    }
475
476    /// Set the cell preview for inspect overlays
477    pub fn cell_preview(mut self, preview: CellPreview) -> Self {
478        self.cell_preview = Some(preview);
479        self
480    }
481
482    /// Set the cell preview (mutable reference version)
483    pub fn set_cell_preview(&mut self, preview: CellPreview) {
484        self.cell_preview = Some(preview);
485    }
486
487    /// Build the final table overlay with the given title
488    pub fn finish(self, title: impl Into<String>) -> DebugTableOverlay {
489        DebugTableOverlay {
490            title: title.into(),
491            rows: self.rows,
492            cell_preview: self.cell_preview,
493        }
494    }
495
496    /// Build as an inspect overlay
497    pub fn finish_inspect(self, title: impl Into<String>) -> DebugOverlay {
498        DebugOverlay::Inspect(self.finish(title))
499    }
500
501    /// Build as a state overlay
502    pub fn finish_state(self, title: impl Into<String>) -> DebugOverlay {
503        DebugOverlay::State(self.finish(title))
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    #[test]
512    fn test_builder_basic() {
513        let table = DebugTableBuilder::new()
514            .section("Test")
515            .entry("key1", "value1")
516            .entry("key2", "value2")
517            .finish("Test Table");
518
519        assert_eq!(table.title, "Test Table");
520        assert_eq!(table.rows.len(), 3);
521        assert!(table.cell_preview.is_none());
522    }
523
524    #[test]
525    fn test_builder_multiple_sections() {
526        let table = DebugTableBuilder::new()
527            .section("Section 1")
528            .entry("a", "1")
529            .section("Section 2")
530            .entry("b", "2")
531            .finish("Multi-Section");
532
533        assert_eq!(table.rows.len(), 4);
534
535        match &table.rows[0] {
536            DebugTableRow::Section(s) => assert_eq!(s, "Section 1"),
537            _ => panic!("Expected section"),
538        }
539        match &table.rows[2] {
540            DebugTableRow::Section(s) => assert_eq!(s, "Section 2"),
541            _ => panic!("Expected section"),
542        }
543    }
544
545    #[test]
546    fn test_overlay_kinds() {
547        let table = DebugTableBuilder::new().finish("Test");
548
549        let inspect = DebugOverlay::Inspect(table.clone());
550        assert_eq!(inspect.kind(), "inspect");
551        assert!(inspect.table().is_some());
552        assert!(inspect.action_log().is_none());
553
554        let state = DebugOverlay::State(table);
555        assert_eq!(state.kind(), "state");
556
557        let action_log = ActionLogOverlay {
558            title: "Test".to_string(),
559            entries: vec![],
560            selected: 0,
561            scroll_offset: 0,
562            search_query: String::new(),
563            search_matches: vec![],
564            search_match_index: 0,
565            search_input_active: false,
566        };
567        let log_overlay = DebugOverlay::ActionLog(action_log);
568        assert_eq!(log_overlay.kind(), "action_log");
569        assert!(log_overlay.table().is_none());
570        assert!(log_overlay.action_log().is_some());
571    }
572
573    #[test]
574    fn test_action_log_overlay_scrolling() {
575        let mut overlay = ActionLogOverlay {
576            title: "Test".to_string(),
577            entries: vec![
578                ActionLogDisplayEntry {
579                    sequence: 0,
580                    name: "A".to_string(),
581                    params: "".to_string(),
582                    params_detail: "".to_string(),
583                    elapsed: "0ms".to_string(),
584                },
585                ActionLogDisplayEntry {
586                    sequence: 1,
587                    name: "B".to_string(),
588                    params: "x: 1".to_string(),
589                    params_detail: "x: 1".to_string(),
590                    elapsed: "1ms".to_string(),
591                },
592                ActionLogDisplayEntry {
593                    sequence: 2,
594                    name: "C".to_string(),
595                    params: "y: 2".to_string(),
596                    params_detail: "y: 2".to_string(),
597                    elapsed: "2ms".to_string(),
598                },
599            ],
600            selected: 0,
601            scroll_offset: 0,
602            search_query: String::new(),
603            search_matches: vec![],
604            search_match_index: 0,
605            search_input_active: false,
606        };
607
608        assert_eq!(overlay.selected, 0);
609
610        overlay.scroll_down();
611        assert_eq!(overlay.selected, 1);
612
613        overlay.scroll_down();
614        assert_eq!(overlay.selected, 2);
615
616        overlay.scroll_down(); // Should not go past end
617        assert_eq!(overlay.selected, 2);
618
619        overlay.scroll_up();
620        assert_eq!(overlay.selected, 1);
621
622        overlay.scroll_to_top();
623        assert_eq!(overlay.selected, 0);
624
625        overlay.scroll_to_bottom();
626        assert_eq!(overlay.selected, 2);
627    }
628
629    #[test]
630    fn test_action_log_overlay_search_query_and_navigation() {
631        let mut overlay = ActionLogOverlay {
632            title: "Test".to_string(),
633            entries: vec![
634                ActionLogDisplayEntry {
635                    sequence: 10,
636                    name: "SearchStart".to_string(),
637                    params: "query: \"foo\"".to_string(),
638                    params_detail: "query: \"foo\"".to_string(),
639                    elapsed: "0ms".to_string(),
640                },
641                ActionLogDisplayEntry {
642                    sequence: 11,
643                    name: "SearchSubmit".to_string(),
644                    params: "query: \"foo\"".to_string(),
645                    params_detail: "query: \"foo\"".to_string(),
646                    elapsed: "1ms".to_string(),
647                },
648                ActionLogDisplayEntry {
649                    sequence: 12,
650                    name: "Connect".to_string(),
651                    params: "host: \"localhost\"".to_string(),
652                    params_detail: "host: \"localhost\"".to_string(),
653                    elapsed: "2ms".to_string(),
654                },
655            ],
656            selected: 0,
657            scroll_offset: 0,
658            search_query: String::new(),
659            search_matches: vec![],
660            search_match_index: 0,
661            search_input_active: false,
662        };
663
664        overlay.set_search_query("search");
665        assert!(overlay.has_search_query());
666        assert_eq!(overlay.search_match_count(), 2);
667        assert_eq!(overlay.selected, 0);
668        assert_eq!(overlay.search_match_position(), Some((1, 2)));
669
670        assert!(overlay.search_next());
671        assert_eq!(overlay.selected, 1);
672        assert_eq!(overlay.search_match_position(), Some((2, 2)));
673
674        assert!(overlay.search_next());
675        assert_eq!(overlay.selected, 0);
676        assert_eq!(overlay.search_match_position(), Some((1, 2)));
677
678        assert!(overlay.search_prev());
679        assert_eq!(overlay.selected, 1);
680        assert_eq!(overlay.search_match_position(), Some((2, 2)));
681        assert_eq!(overlay.search_matches, vec![0, 1]);
682    }
683
684    #[test]
685    fn test_action_log_overlay_search_edge_cases() {
686        let mut overlay = ActionLogOverlay {
687            title: "Test".to_string(),
688            entries: vec![ActionLogDisplayEntry {
689                sequence: 0,
690                name: "Connect".to_string(),
691                params: "host: \"example\"".to_string(),
692                params_detail: "host: \"example\"".to_string(),
693                elapsed: "0ms".to_string(),
694            }],
695            selected: 0,
696            scroll_offset: 0,
697            search_query: String::new(),
698            search_matches: vec![],
699            search_match_index: 0,
700            search_input_active: false,
701        };
702
703        overlay.set_search_query("missing");
704        assert_eq!(overlay.search_match_count(), 0);
705        assert!(!overlay.search_next());
706        assert!(!overlay.search_prev());
707        assert_eq!(overlay.search_match_position(), None);
708
709        overlay.set_search_query("connect");
710        assert_eq!(overlay.search_match_count(), 1);
711        assert_eq!(overlay.search_match_position(), Some((1, 1)));
712        assert!(overlay.search_next());
713        assert_eq!(overlay.search_match_position(), Some((1, 1)));
714
715        assert!(overlay.pop_search_char());
716        assert!(overlay.has_search_query());
717        overlay.clear_search_query();
718        assert!(!overlay.has_search_query());
719        assert_eq!(overlay.search_match_count(), 0);
720    }
721
722    #[test]
723    fn test_action_log_overlay_scroll_respects_filter() {
724        let mut overlay = ActionLogOverlay {
725            title: "Test".to_string(),
726            entries: vec![
727                ActionLogDisplayEntry {
728                    sequence: 0,
729                    name: "SearchStart".to_string(),
730                    params: "".to_string(),
731                    params_detail: "".to_string(),
732                    elapsed: "0ms".to_string(),
733                },
734                ActionLogDisplayEntry {
735                    sequence: 1,
736                    name: "Connect".to_string(),
737                    params: "".to_string(),
738                    params_detail: "".to_string(),
739                    elapsed: "1ms".to_string(),
740                },
741                ActionLogDisplayEntry {
742                    sequence: 2,
743                    name: "SearchSubmit".to_string(),
744                    params: "".to_string(),
745                    params_detail: "".to_string(),
746                    elapsed: "2ms".to_string(),
747                },
748            ],
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
757        overlay.set_search_query("search");
758        assert_eq!(overlay.search_matches, vec![0, 2]);
759        assert_eq!(overlay.selected, 0);
760
761        overlay.scroll_down();
762        assert_eq!(overlay.selected, 2);
763        overlay.scroll_up();
764        assert_eq!(overlay.selected, 0);
765    }
766}