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 ratatui::layout::Rect;
8
9use super::action_logger::ActionLog;
10use super::cell::CellPreview;
11
12/// A row in a debug table - either a section header or a key-value entry
13#[derive(Debug, Clone)]
14pub enum DebugTableRow {
15    /// Section header (e.g., "Connection", "Keys", "UI")
16    Section(String),
17    /// Key-value entry (e.g., "host" -> "localhost")
18    Entry { key: String, value: String },
19}
20
21/// A debug table overlay with title, rows, and optional cell preview
22#[derive(Debug, Clone)]
23pub struct DebugTableOverlay {
24    /// Title displayed at the top of the overlay
25    pub title: String,
26    /// Table rows (sections and entries)
27    pub rows: Vec<DebugTableRow>,
28    /// Optional cell preview for inspect overlays
29    pub cell_preview: Option<CellPreview>,
30}
31
32impl DebugTableOverlay {
33    /// Create a new overlay with the given title and rows
34    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    /// Create a new overlay with cell preview
43    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/// Type of debug overlay
57#[derive(Debug, Clone)]
58pub enum DebugOverlay {
59    /// Inspect overlay - shows info about a specific position/cell
60    Inspect(DebugTableOverlay),
61    /// State overlay - shows full application state
62    State(DebugTableOverlay),
63    /// Action log overlay - shows recent actions with timestamps
64    ActionLog(ActionLogOverlay),
65    /// Action detail overlay - shows full details of a single action
66    ActionDetail(ActionDetailOverlay),
67    /// Components overlay - shows mounted component debug info
68    Components(ComponentsOverlay),
69    /// State entry detail - shows full value for a single state entry
70    StateDetail(StateEntryDetail),
71    /// Component detail - shows full info for a single component
72    ComponentDetail(ComponentDetailOverlay),
73}
74
75/// Overlay for displaying detailed action information
76#[derive(Debug, Clone)]
77pub struct ActionDetailOverlay {
78    /// Sequence number
79    pub sequence: u64,
80    /// Action name
81    pub name: String,
82    /// Full action parameters
83    pub params: String,
84    /// Elapsed time display
85    pub elapsed: String,
86}
87
88/// Detail view for a single state tree entry (full untruncated value).
89#[derive(Debug, Clone)]
90pub struct StateEntryDetail {
91    /// Section name this entry belongs to
92    pub section: String,
93    /// Entry key
94    pub key: String,
95    /// Full entry value (untruncated)
96    pub value: String,
97}
98
99/// Detail view for a single mounted component.
100#[derive(Debug, Clone)]
101pub struct ComponentDetailOverlay {
102    /// Index in the components list (for restoring selection on back)
103    pub index: usize,
104    /// Short type name
105    pub type_name: String,
106    /// Full qualified type name
107    pub type_name_full: String,
108    /// Bound component ID name
109    pub bound_id: Option<String>,
110    /// Last rendered area
111    pub last_area: Option<Rect>,
112    /// Debug state key-value pairs
113    pub debug_entries: Vec<(String, String)>,
114}
115
116impl ComponentDetailOverlay {
117    /// Create from a component snapshot.
118    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/// A non-generic snapshot of a single mounted component's debug info.
131#[derive(Debug, Clone)]
132pub struct ComponentSnapshot {
133    /// Internal mount handle id
134    pub raw_id: u32,
135    /// Short type name (last segment of the full path)
136    pub type_name: String,
137    /// Full qualified type name
138    pub type_name_full: String,
139    /// The component ID name it's bound to, if any
140    pub bound_id: Option<String>,
141    /// Last rendered area, if any
142    pub last_area: Option<Rect>,
143    /// Debug state key-value pairs
144    pub debug_entries: Vec<(String, String)>,
145}
146
147impl ComponentSnapshot {
148    /// Create from a `MountedComponentInfo` where `Id: ComponentId`.
149    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/// Overlay for displaying mounted components
174#[derive(Debug, Clone)]
175pub struct ComponentsOverlay {
176    /// Title for the overlay
177    pub title: String,
178    /// Snapshots of all mounted components
179    pub components: Vec<ComponentSnapshot>,
180    /// Currently selected component index
181    pub selected: usize,
182    /// Set of expanded component indices (show debug entries)
183    pub expanded: std::collections::HashSet<usize>,
184}
185
186impl ComponentsOverlay {
187    /// Create from a vec of snapshots.
188    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    /// Scroll up (select previous component)
198    pub fn scroll_up(&mut self) {
199        self.selected = self.selected.saturating_sub(1);
200    }
201
202    /// Scroll down (select next component)
203    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    /// Jump to the top
210    pub fn scroll_to_top(&mut self) {
211        self.selected = 0;
212    }
213
214    /// Jump to the bottom
215    pub fn scroll_to_bottom(&mut self) {
216        if !self.components.is_empty() {
217            self.selected = self.components.len() - 1;
218        }
219    }
220
221    /// Page up
222    pub fn page_up(&mut self, page_size: usize) {
223        self.selected = self.selected.saturating_sub(page_size);
224    }
225
226    /// Page down
227    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    /// Toggle expansion of the currently selected component.
234    pub fn toggle_expanded(&mut self) {
235        if !self.expanded.remove(&self.selected) {
236            self.expanded.insert(self.selected);
237        }
238    }
239
240    /// Whether the given index is expanded.
241    pub fn is_expanded(&self, index: usize) -> bool {
242        self.expanded.contains(&index)
243    }
244}
245
246impl DebugOverlay {
247    /// Get the underlying table from the overlay (for Table/State/Inspect)
248    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    /// Get the action log overlay
260    pub fn action_log(&self) -> Option<&ActionLogOverlay> {
261        match self {
262            DebugOverlay::ActionLog(log) => Some(log),
263            _ => None,
264        }
265    }
266
267    /// Get the action log overlay mutably
268    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    /// Get the components overlay
276    pub fn components(&self) -> Option<&ComponentsOverlay> {
277        match self {
278            DebugOverlay::Components(c) => Some(c),
279            _ => None,
280        }
281    }
282
283    /// Get the components overlay mutably
284    pub fn components_mut(&mut self) -> Option<&mut ComponentsOverlay> {
285        match self {
286            DebugOverlay::Components(c) => Some(c),
287            _ => None,
288        }
289    }
290
291    /// Get the overlay kind as a string
292    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// ============================================================================
306// Action Log Overlay
307// ============================================================================
308
309/// A display-ready action log entry
310#[derive(Debug, Clone)]
311pub struct ActionLogDisplayEntry {
312    /// Sequence number
313    pub sequence: u64,
314    /// Action name
315    pub name: String,
316    /// Action parameters (without the action name)
317    pub params: String,
318    /// Pretty action parameters for detail view
319    pub params_detail: String,
320    /// Elapsed time display (e.g., "2.3s")
321    pub elapsed: String,
322}
323
324/// Overlay for displaying the action log
325#[derive(Debug, Clone)]
326pub struct ActionLogOverlay {
327    /// Title for the overlay
328    pub title: String,
329    /// Action entries to display
330    pub entries: Vec<ActionLogDisplayEntry>,
331    /// Currently selected entry index (for scrolling)
332    pub selected: usize,
333    /// Scroll offset for visible window
334    pub scroll_offset: usize,
335    /// Active action-log search query
336    pub search_query: String,
337    /// Matched row indices for the current search query
338    pub search_matches: Vec<usize>,
339    /// Current position within `search_matches`
340    pub search_match_index: usize,
341    /// Whether search input mode is active
342    pub search_input_active: bool,
343}
344
345impl ActionLogOverlay {
346    /// Create from an ActionLog reference
347    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    /// Scroll up (select previous entry)
372    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    /// Scroll down (select next entry)
383    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    /// Jump to the top
394    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    /// Jump to the bottom
403    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    /// Page up
414    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    /// Page down
423    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    /// Calculate the scroll offset to keep the selected row visible.
432    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    /// Get the currently selected entry
444    pub fn get_selected(&self) -> Option<&ActionLogDisplayEntry> {
445        self.entries.get(self.selected)
446    }
447
448    /// Create a detail overlay from the selected entry
449    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    /// Set the current search query and recompute matching rows.
459    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    /// Append a character to the active search query.
465    pub fn push_search_char(&mut self, ch: char) {
466        self.search_query.push(ch);
467        self.rebuild_search_matches();
468    }
469
470    /// Pop the last search query character.
471    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    /// Clear the active search query and matches.
480    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    /// Move to the next match, wrapping at the end.
487    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    /// Move to the previous match, wrapping at the beginning.
497    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    /// Returns true when a search query is active.
511    pub fn has_search_query(&self) -> bool {
512        !self.search_query.is_empty()
513    }
514
515    // Returns `true` when filtered mode handled navigation, so callers should
516    // skip unfiltered selection movement for this key event.
517    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            // Keep navigation scoped to filtered rows while a query is active.
526            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    /// Number of matching rows for the active query.
537    pub fn search_match_count(&self) -> usize {
538        self.search_matches.len()
539    }
540
541    /// Current search match position as `(index, total)` (1-based index).
542    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    /// Returns true if this row index is currently matched by the query.
551    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                // Preserves ascending order so membership checks can use binary search.
570                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/// Builder for constructing debug tables
608///
609/// # Example
610///
611/// ```
612/// use tui_dispatch_debug::debug::{DebugTableBuilder, DebugTableRow};
613///
614/// let table = DebugTableBuilder::new()
615///     .section("Connection")
616///     .entry("host", "localhost")
617///     .entry("port", "6379")
618///     .section("Status")
619///     .entry("connected", "true")
620///     .finish("Connection Info");
621///
622/// assert_eq!(table.title, "Connection Info");
623/// assert_eq!(table.rows.len(), 5);
624/// ```
625#[derive(Debug, Default)]
626pub struct DebugTableBuilder {
627    rows: Vec<DebugTableRow>,
628    cell_preview: Option<CellPreview>,
629}
630
631impl DebugTableBuilder {
632    /// Create a new empty builder
633    pub fn new() -> Self {
634        Self::default()
635    }
636
637    /// Add a section header
638    pub fn section(mut self, title: impl Into<String>) -> Self {
639        self.rows.push(DebugTableRow::Section(title.into()));
640        self
641    }
642
643    /// Add a key-value entry
644    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    /// Add a section header (mutable reference version)
653    pub fn push_section(&mut self, title: impl Into<String>) {
654        self.rows.push(DebugTableRow::Section(title.into()));
655    }
656
657    /// Add a key-value entry (mutable reference version)
658    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    /// Set the cell preview for inspect overlays
666    pub fn cell_preview(mut self, preview: CellPreview) -> Self {
667        self.cell_preview = Some(preview);
668        self
669    }
670
671    /// Set the cell preview (mutable reference version)
672    pub fn set_cell_preview(&mut self, preview: CellPreview) {
673        self.cell_preview = Some(preview);
674    }
675
676    /// Build the final table overlay with the given title
677    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    /// Build as an inspect overlay
686    pub fn finish_inspect(self, title: impl Into<String>) -> DebugOverlay {
687        DebugOverlay::Inspect(self.finish(title))
688    }
689
690    /// Build as a state overlay
691    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(); // Should not go past end
806        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}