Skip to main content

tui_dispatch_debug/debug/
layer.rs

1//! High-level debug layer for TUI applications
2//!
3//! Provides a self-contained debug overlay with automatic pause/resume of
4//! tasks and subscriptions.
5
6use std::any::Any;
7use std::collections::HashSet;
8use std::io::{self, Write};
9use std::path::{Path, PathBuf};
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use base64::prelude::*;
13use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind};
14use ratatui::buffer::Buffer;
15use ratatui::layout::Rect;
16use ratatui::style::{Modifier, Style, Stylize};
17use ratatui::text::{Line, Span};
18use ratatui::Frame;
19use serde::Serialize;
20
21use super::action_logger::{ActionLog, ActionLogConfig, ActionLoggerConfig};
22use super::actions::{DebugAction, DebugSideEffect};
23use super::cell::inspect_cell;
24use super::config::DebugStyle;
25use super::state::DebugState;
26use super::table::{
27    ActionLogOverlay, DebugOverlay, DebugTableBuilder, DebugTableOverlay, DebugTableRow,
28};
29use super::widgets::{
30    debug_spans, dim_buffer, paint_snapshot, ActionLogStyle, CellPreviewWidget, DebugSyntaxStyle,
31    DebugTableStyle,
32};
33use super::DebugFreeze;
34
35use tui_dispatch_components::{
36    centered_rect, BaseStyle, LinesScroller, Modal, ModalBehavior, ModalProps, ModalStyle, Padding,
37    ScrollView, ScrollViewBehavior, ScrollViewProps, ScrollViewStyle,
38    ScrollbarStyle as ComponentScrollbarStyle, SelectionStyle, StatusBar, StatusBarItem,
39    StatusBarProps, StatusBarSection, StatusBarStyle, TreeBranchMode, TreeBranchStyle, TreeNode,
40    TreeNodeRender, TreeView, TreeViewBehavior, TreeViewProps, TreeViewStyle,
41};
42
43#[cfg(feature = "subscriptions")]
44use tui_dispatch_core::subscriptions::SubPauseHandle;
45#[cfg(feature = "tasks")]
46use tui_dispatch_core::tasks::TaskPauseHandle;
47use tui_dispatch_core::{Action, Component, EventKind};
48
49type StateSnapshotter = Box<dyn Fn(&dyn Any, &Path) -> crate::SnapshotResult<()> + 'static>;
50
51#[derive(Debug, Clone)]
52enum StateTreeAction {
53    Select(String),
54    Toggle(String, bool),
55}
56
57fn state_tree_select(id: &str) -> StateTreeAction {
58    StateTreeAction::Select(id.to_owned())
59}
60
61fn state_tree_toggle(id: &str, expanded: bool) -> StateTreeAction {
62    StateTreeAction::Toggle(id.to_owned(), expanded)
63}
64
65struct InlineValueStyle {
66    base: Style,
67    key: Style,
68    string: Style,
69    number: Style,
70    r#type: Style,
71}
72
73fn ron_value_spans(value: &str, style: &InlineValueStyle) -> Vec<Span<'static>> {
74    let chars: Vec<char> = value.chars().collect();
75    let mut spans = Vec::new();
76    let mut idx = 0;
77
78    while idx < chars.len() {
79        let current = chars[idx];
80        if current == '"' {
81            let start = idx;
82            idx += 1;
83            while idx < chars.len() {
84                if chars[idx] == '"' && chars.get(idx.saturating_sub(1)) != Some(&'\\') {
85                    idx += 1;
86                    break;
87                }
88                idx += 1;
89            }
90            let text: String = chars[start..idx].iter().collect();
91            spans.push(Span::styled(text, style.string));
92            continue;
93        }
94
95        if current.is_ascii_digit()
96            || (current == '-' && chars.get(idx + 1).is_some_and(|c| c.is_ascii_digit()))
97        {
98            let start = idx;
99            idx += 1;
100            while idx < chars.len() {
101                let c = chars[idx];
102                if c.is_ascii_digit() || matches!(c, '.' | '_' | 'e' | 'E' | '+' | '-') {
103                    idx += 1;
104                } else {
105                    break;
106                }
107            }
108            let text: String = chars[start..idx].iter().collect();
109            spans.push(Span::styled(text, style.number));
110            continue;
111        }
112
113        if current.is_alphanumeric() || current == '_' {
114            let start = idx;
115            idx += 1;
116            while idx < chars.len() {
117                let c = chars[idx];
118                if c.is_alphanumeric() || c == '_' {
119                    idx += 1;
120                } else {
121                    break;
122                }
123            }
124            let mut text: String = chars[start..idx].iter().collect();
125            let mut span_style = style.base;
126
127            if idx < chars.len() && chars[idx] == ':' {
128                text.push(':');
129                idx += 1;
130                span_style = style.key;
131            } else if text == "true" || text == "false" {
132                span_style = style.number;
133            } else if text.chars().next().is_some_and(|c| c.is_uppercase()) {
134                span_style = style.r#type;
135            }
136
137            spans.push(Span::styled(text, span_style));
138            continue;
139        }
140
141        spans.push(Span::styled(current.to_string(), style.base));
142        idx += 1;
143    }
144
145    spans
146}
147
148fn render_state_tree_node(ctx: TreeNodeRender<'_, String, String>) -> Line<'static> {
149    // Section header style - bold accent color for expandable sections
150    let section_style = Style::default()
151        .fg(DebugStyle::accent())
152        .add_modifier(Modifier::BOLD);
153    // Key/field label style
154    let key_style = Style::default().fg(DebugStyle::text_primary()).bold();
155    // Value styling
156    let value_style = Style::default().fg(DebugStyle::text_primary());
157    let type_style = Style::default().fg(DebugStyle::neon_purple());
158    let string_style = Style::default().fg(DebugStyle::neon_green());
159    let number_style = Style::default().fg(DebugStyle::neon_amber());
160
161    let selection_patch = if ctx.is_selected {
162        Style::default().bg(DebugStyle::bg_highlight())
163    } else {
164        Style::default()
165    };
166
167    let content_width = ctx.available_width.max(1);
168    let mut spans = Vec::new();
169
170    // Section headers (nodes with children) - bold accent style
171    if ctx.has_children {
172        let text = truncate_with_ellipsis(&ctx.node.value, content_width);
173        spans.push(Span::styled(text, section_style).patch_style(selection_patch));
174        return Line::from(spans);
175    }
176
177    // Key: value pairs - render as "key: value" with syntax highlighting
178    if let Some((key, value)) = ctx.node.value.split_once(": ") {
179        let key_text = format!("{key}: ");
180        let key_len = key_text.chars().count();
181        spans.push(Span::styled(key_text, key_style).patch_style(selection_patch));
182
183        let remaining = content_width.saturating_sub(key_len);
184        if remaining > 0 {
185            let value_text = truncate_with_ellipsis(value, remaining);
186            let inline_style = InlineValueStyle {
187                base: value_style,
188                key: key_style,
189                string: string_style,
190                number: number_style,
191                r#type: type_style,
192            };
193            let value_spans = ron_value_spans(&value_text, &inline_style);
194            for span in value_spans {
195                spans.push(span.patch_style(selection_patch));
196            }
197        }
198
199        return Line::from(spans);
200    }
201
202    // Plain text nodes
203    let text = truncate_with_ellipsis(&ctx.node.value, content_width);
204    spans.push(Span::styled(text, value_style).patch_style(selection_patch));
205
206    Line::from(spans)
207}
208
209/// Location of the debug banner relative to the app area.
210#[derive(Clone, Copy, Debug, PartialEq, Eq)]
211pub enum BannerPosition {
212    Bottom,
213    Top,
214}
215
216impl BannerPosition {
217    /// Toggle between top and bottom.
218    pub fn toggle(self) -> Self {
219        match self {
220            Self::Bottom => Self::Top,
221            Self::Top => Self::Bottom,
222        }
223    }
224
225    fn label(self) -> &'static str {
226        match self {
227            Self::Bottom => "bar:bottom",
228            Self::Top => "bar:top",
229        }
230    }
231}
232
233// ============================================================================
234// Modal Footer Hints
235// ============================================================================
236
237/// A single hint for modal footer status bar
238#[derive(Debug, Clone, Copy)]
239pub struct ModalHint {
240    pub key: &'static str,
241    pub label: &'static str,
242}
243
244impl ModalHint {
245    pub const fn new(key: &'static str, label: &'static str) -> Self {
246        Self { key, label }
247    }
248}
249
250/// Hints configuration for a modal footer
251#[derive(Debug, Clone, Copy)]
252pub struct ModalHints {
253    pub left: &'static [ModalHint],
254    pub right: &'static [ModalHint],
255}
256
257// Action Log Modal hints
258const ACTION_LOG_HINTS: ModalHints = ModalHints {
259    left: &[
260        ModalHint::new("j/k", "scroll"),
261        ModalHint::new("g/G", "top/bottom"),
262        ModalHint::new("/", "filter"),
263    ],
264    right: &[
265        ModalHint::new("n/N", "next/prev"),
266        ModalHint::new("Enter", "details"),
267        ModalHint::new("Bksp", "close"),
268    ],
269};
270
271// Action Log Modal hints while search input is active
272const ACTION_LOG_SEARCH_INPUT_HINTS: ModalHints = ModalHints {
273    left: &[
274        ModalHint::new("type", "query"),
275        ModalHint::new("Bksp", "edit"),
276    ],
277    right: &[
278        ModalHint::new("Enter", "done"),
279        ModalHint::new("Esc", "done"),
280    ],
281};
282
283// State Tree Modal hints
284const STATE_TREE_HINTS: ModalHints = ModalHints {
285    left: &[
286        ModalHint::new("j/k", "scroll"),
287        ModalHint::new("Space", "expand"),
288    ],
289    right: &[ModalHint::new("w", "save"), ModalHint::new("Bksp", "close")],
290};
291
292// Action Detail Modal hints
293const ACTION_DETAIL_HINTS: ModalHints = ModalHints {
294    left: &[ModalHint::new("j/k", "scroll")],
295    right: &[ModalHint::new("Bksp", "back")],
296};
297
298// Inspect Modal hints
299const INSPECT_HINTS: ModalHints = ModalHints {
300    left: &[ModalHint::new("j/k", "scroll")],
301    right: &[ModalHint::new("Bksp", "close")],
302};
303
304/// Result of handling a debug event.
305pub struct DebugOutcome<A> {
306    /// Whether the debug layer consumed the event.
307    pub consumed: bool,
308    /// Actions queued while debug was active (e.g., from pause/resume).
309    pub queued_actions: Vec<A>,
310    /// Whether a re-render is needed.
311    pub needs_render: bool,
312}
313
314impl<A> DebugOutcome<A> {
315    fn ignored() -> Self {
316        Self {
317            consumed: false,
318            queued_actions: Vec::new(),
319            needs_render: false,
320        }
321    }
322
323    fn consumed(queued_actions: Vec<A>) -> Self {
324        Self {
325            consumed: true,
326            queued_actions,
327            needs_render: true,
328        }
329    }
330
331    /// Dispatch queued actions if the debug layer consumed the event.
332    ///
333    /// Returns `Some(needs_render)` when consumed, otherwise `None`.
334    pub fn dispatch_queued<F>(self, mut dispatch: F) -> Option<bool>
335    where
336        F: FnMut(A),
337    {
338        if !self.consumed {
339            return None;
340        }
341
342        for action in self.queued_actions {
343            dispatch(action);
344        }
345
346        Some(self.needs_render)
347    }
348}
349
350impl<A> Default for DebugOutcome<A> {
351    fn default() -> Self {
352        Self::ignored()
353    }
354}
355
356/// High-level debug layer with minimal configuration.
357///
358/// Provides automatic freeze/unfreeze with pause/resume of tasks and subscriptions.
359///
360/// # Example
361///
362/// ```ignore
363/// use tui_dispatch::debug::DebugLayer;
364///
365/// // Minimal setup with sensible defaults (F12 toggle key)
366/// let mut debug = DebugLayer::simple()
367///     .with_task_manager(&tasks)
368///     .with_subscriptions(&subs)
369///     .active(args.debug);
370///
371/// // In event loop
372/// if debug.intercepts(&event) {
373///     continue;
374/// }
375///
376/// // In render
377/// debug.render(frame, |f, area| {
378///     app.render(f, area);
379/// });
380///
381/// // Log actions for the action log feature
382/// debug.log_action(&action);
383/// ```
384pub struct DebugLayer<A> {
385    /// Key to toggle debug mode
386    toggle_key: KeyCode,
387    /// Internal freeze state
388    freeze: DebugFreeze<A>,
389    /// Where the debug banner is rendered
390    banner_position: BannerPosition,
391    /// Style configuration
392    style: DebugStyle,
393    /// Whether the debug layer is active (can be disabled for release builds)
394    active: bool,
395    /// Action log for display
396    action_log: ActionLog,
397    /// Cached state snapshot for the state overlay
398    state_snapshot: Option<DebugTableOverlay>,
399    /// Optional serializer for saving state snapshots
400    state_snapshotter: Option<StateSnapshotter>,
401    /// Tree view component for state overlay
402    state_tree_view: TreeView<String>,
403    /// Cached tree nodes for state overlay
404    state_tree_nodes: Vec<TreeNode<String, String>>,
405    /// Currently selected tree node id
406    state_tree_selected: Option<String>,
407    /// Expanded node ids for the state tree
408    state_tree_expanded: HashSet<String>,
409    /// Scroll offset for state/inspect table overlays
410    table_scroll_offset: usize,
411    /// Cached page size for table overlay scrolling
412    table_page_size: usize,
413    /// Scroll offset for action detail modal
414    detail_scroll_offset: usize,
415    /// Cached page size for action detail scrolling
416    detail_page_size: usize,
417    /// Handle to pause/resume task manager
418    #[cfg(feature = "tasks")]
419    task_handle: Option<TaskPauseHandle<A>>,
420    /// Handle to pause/resume subscriptions
421    #[cfg(feature = "subscriptions")]
422    sub_handle: Option<SubPauseHandle>,
423}
424
425impl<A> std::fmt::Debug for DebugLayer<A> {
426    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
427        f.debug_struct("DebugLayer")
428            .field("toggle_key", &self.toggle_key)
429            .field("active", &self.active)
430            .field("enabled", &self.freeze.enabled)
431            .field("has_snapshot", &self.freeze.snapshot.is_some())
432            .field("has_state_snapshot", &self.state_snapshot.is_some())
433            .field("has_state_snapshotter", &self.state_snapshotter.is_some())
434            .field("state_tree_nodes", &self.state_tree_nodes.len())
435            .field("state_tree_selected", &self.state_tree_selected)
436            .field("banner_position", &self.banner_position)
437            .field("table_scroll_offset", &self.table_scroll_offset)
438            .field("detail_scroll_offset", &self.detail_scroll_offset)
439            .field("queued_actions", &self.freeze.queued_actions.len())
440            .finish()
441    }
442}
443
444impl<A: Action> DebugLayer<A> {
445    /// Create a new debug layer with the given toggle key.
446    ///
447    /// # Example
448    ///
449    /// ```ignore
450    /// use crossterm::event::KeyCode;
451    ///
452    /// let debug = DebugLayer::new(KeyCode::F(12));
453    /// ```
454    pub fn new(toggle_key: KeyCode) -> Self {
455        Self {
456            toggle_key,
457            freeze: DebugFreeze::new(),
458            banner_position: BannerPosition::Bottom,
459            style: DebugStyle::default(),
460            active: true,
461            action_log: ActionLog::new(ActionLogConfig::with_capacity(100)),
462            state_snapshot: None,
463            state_snapshotter: None,
464            state_tree_view: TreeView::new(),
465            state_tree_nodes: Vec::new(),
466            state_tree_selected: None,
467            state_tree_expanded: HashSet::new(),
468            table_scroll_offset: 0,
469            table_page_size: 1,
470            detail_scroll_offset: 0,
471            detail_page_size: 1,
472            #[cfg(feature = "tasks")]
473            task_handle: None,
474            #[cfg(feature = "subscriptions")]
475            sub_handle: None,
476        }
477    }
478
479    /// Create a debug layer with sensible defaults (F12 toggle key).
480    pub fn simple() -> Self {
481        Self::new(KeyCode::F(12))
482    }
483
484    /// Create a debug layer with a custom toggle key.
485    pub fn simple_with_toggle_key(toggle_key: KeyCode) -> Self {
486        Self::new(toggle_key)
487    }
488
489    /// Set whether the debug layer is active.
490    ///
491    /// When inactive (`false`), all methods become no-ops with zero overhead.
492    pub fn active(mut self, active: bool) -> Self {
493        self.active = active;
494        self
495    }
496
497    /// Set the initial banner position.
498    pub fn with_banner_position(mut self, position: BannerPosition) -> Self {
499        self.banner_position = position;
500        self
501    }
502
503    /// Connect a task manager for automatic pause/resume.
504    ///
505    /// When debug mode is enabled, the task manager will be paused.
506    /// When disabled, queued actions will be returned.
507    #[cfg(feature = "tasks")]
508    pub fn with_task_manager(mut self, tasks: &tui_dispatch_core::tasks::TaskManager<A>) -> Self {
509        self.task_handle = Some(tasks.pause_handle());
510        self
511    }
512
513    /// Connect subscriptions for automatic pause/resume.
514    ///
515    /// When debug mode is enabled, subscriptions will be paused.
516    #[cfg(feature = "subscriptions")]
517    pub fn with_subscriptions(
518        mut self,
519        subs: &tui_dispatch_core::subscriptions::Subscriptions<A>,
520    ) -> Self {
521        self.sub_handle = Some(subs.pause_handle());
522        self
523    }
524
525    /// Set the action log capacity.
526    pub fn with_action_log_capacity(mut self, capacity: usize) -> Self {
527        self.action_log = ActionLog::new(ActionLogConfig::with_capacity(capacity));
528        self
529    }
530
531    /// Set full action-log configuration (capacity + filter).
532    pub fn with_action_log_config(mut self, config: ActionLogConfig) -> Self {
533        self.action_log = ActionLog::new(config);
534        self
535    }
536
537    /// Set action-log filtering while keeping the current capacity.
538    pub fn with_action_log_filter(mut self, filter: ActionLoggerConfig) -> Self {
539        let capacity = self.action_log.config().capacity;
540        self.action_log = ActionLog::new(ActionLogConfig::new(capacity, filter));
541        self
542    }
543
544    /// Set custom style.
545    pub fn with_style(mut self, style: DebugStyle) -> Self {
546        self.style = style;
547        self
548    }
549
550    /// Provide a custom serializer for saving state snapshots (W key).
551    pub fn with_state_snapshotter<S, F>(mut self, snapshotter: F) -> Self
552    where
553        S: DebugState + 'static,
554        F: Fn(&S, &Path) -> crate::SnapshotResult<()> + 'static,
555    {
556        self.state_snapshotter = Some(Box::new(move |state, path| {
557            let state = state.downcast_ref::<S>().ok_or_else(|| {
558                crate::SnapshotError::Io(io::Error::new(
559                    io::ErrorKind::InvalidInput,
560                    "debug state snapshot type mismatch",
561                ))
562            })?;
563            snapshotter(state, path)
564        }));
565        self
566    }
567
568    /// Save full state snapshots using serde (loadable by `--debug-state-in`).
569    pub fn with_state_snapshots<S>(self) -> Self
570    where
571        S: DebugState + Serialize + 'static,
572    {
573        self.with_state_snapshotter::<S, _>(|state, path| crate::save_json(path, state))
574    }
575
576    /// Check if the debug layer is active.
577    pub fn is_active(&self) -> bool {
578        self.active
579    }
580
581    /// Check if debug mode is enabled (and layer is active).
582    pub fn is_enabled(&self) -> bool {
583        self.active && self.freeze.enabled
584    }
585
586    /// Toggle debug mode on/off and return any side effects.
587    ///
588    /// Returns `None` when the layer is inactive or no side effects are needed.
589    pub fn toggle_enabled(&mut self) -> Option<DebugSideEffect<A>> {
590        if !self.active {
591            return None;
592        }
593        self.toggle()
594    }
595
596    /// Set debug mode on/off and return any side effects.
597    ///
598    /// Returns `None` when the layer is inactive or already in the requested state.
599    pub fn set_enabled(&mut self, enabled: bool) -> Option<DebugSideEffect<A>> {
600        if !self.active || enabled == self.freeze.enabled {
601            return None;
602        }
603        self.toggle()
604    }
605
606    /// Update the banner position (top/bottom) and request a new capture.
607    pub fn set_banner_position(&mut self, position: BannerPosition) {
608        if self.banner_position != position {
609            self.banner_position = position;
610            if self.freeze.enabled {
611                self.freeze.request_capture();
612            }
613        }
614    }
615
616    /// Check if the state overlay is currently visible.
617    pub fn is_state_overlay_visible(&self) -> bool {
618        matches!(self.freeze.overlay, Some(DebugOverlay::State(_)))
619    }
620
621    /// Get a reference to the underlying freeze state.
622    pub fn freeze(&self) -> &DebugFreeze<A> {
623        &self.freeze
624    }
625
626    /// Get a mutable reference to the underlying freeze state.
627    pub fn freeze_mut(&mut self) -> &mut DebugFreeze<A> {
628        &mut self.freeze
629    }
630
631    /// Log an action to the action log.
632    ///
633    /// Call this when dispatching actions to record them for the debug overlay.
634    pub fn log_action<T: tui_dispatch_core::ActionParams>(&mut self, action: &T) {
635        if self.active {
636            self.action_log.log(action);
637        }
638    }
639
640    /// Get the action log.
641    pub fn action_log(&self) -> &ActionLog {
642        &self.action_log
643    }
644
645    /// Render with automatic debug handling.
646    ///
647    /// When debug mode is disabled, simply calls `render_fn` with the full frame area.
648    /// When enabled, captures/paints the frozen snapshot and renders debug overlay.
649    pub fn render<F>(&mut self, frame: &mut Frame, render_fn: F)
650    where
651        F: FnOnce(&mut Frame, Rect),
652    {
653        self.render_with_state(frame, |frame, area, _wants_state| {
654            render_fn(frame, area);
655            None
656        });
657    }
658
659    /// Render with optional state capture for the state overlay.
660    ///
661    /// `render_fn` receives the frame, app area, and a `wants_state` hint that
662    /// is `true` when debug mode is active and state data may be requested.
663    /// Return `Some(DebugTableOverlay)` to update the cached state overlay.
664    pub fn render_with_state<F>(&mut self, frame: &mut Frame, render_fn: F)
665    where
666        F: FnOnce(&mut Frame, Rect, bool) -> Option<DebugTableOverlay>,
667    {
668        let screen = frame.area();
669
670        // Inactive or not in debug mode: just render normally
671        if !self.active || !self.freeze.enabled {
672            let _ = render_fn(frame, screen, false);
673            return;
674        }
675
676        // Debug mode: reserve line for banner
677        let (app_area, banner_area) = self.split_for_banner(screen);
678
679        if self.freeze.pending_capture || self.freeze.snapshot.is_none() {
680            // Capture mode: render app, then capture
681            let state_snapshot = render_fn(frame, app_area, true);
682            self.state_snapshot = state_snapshot;
683            if let Some(ref table) = self.state_snapshot {
684                if self.is_state_overlay_visible() {
685                    self.set_state_overlay(table.clone());
686                }
687            }
688            let buffer_clone = frame.buffer_mut().clone();
689            self.freeze.capture(&buffer_clone);
690        } else if let Some(ref snapshot) = self.freeze.snapshot {
691            // Frozen: paint snapshot
692            paint_snapshot(frame, snapshot);
693        }
694
695        // Render debug overlay
696        self.render_debug_overlay(frame, app_area, banner_area);
697    }
698
699    /// Render with a DebugState reference and automatic state table generation.
700    ///
701    /// This is a convenience wrapper around `render_with_state`.
702    pub fn render_state<S: DebugState, F>(&mut self, frame: &mut Frame, state: &S, render_fn: F)
703    where
704        F: FnOnce(&mut Frame, Rect),
705    {
706        self.render_with_state(frame, |frame, area, wants_state| {
707            render_fn(frame, area);
708            if wants_state {
709                Some(state.build_debug_table("Application State"))
710            } else {
711                None
712            }
713        });
714    }
715
716    /// Split area for manual layout control.
717    pub fn split_area(&self, area: Rect) -> (Rect, Rect) {
718        if !self.freeze.enabled {
719            return (area, Rect::ZERO);
720        }
721        self.split_for_banner(area)
722    }
723
724    /// Check if debug layer intercepts an event.
725    ///
726    /// Call this before your normal event handling. If it returns `true`,
727    /// the event was consumed by the debug layer.
728    ///
729    /// # Example
730    ///
731    /// ```ignore
732    /// if debug.intercepts(&event) {
733    ///     continue;
734    /// }
735    /// // Normal event handling
736    /// ```
737    pub fn intercepts(&mut self, event: &tui_dispatch_core::EventKind) -> bool {
738        self.intercepts_with_effects(event).is_some()
739    }
740
741    /// Handle a debug event with a single call and return a summary outcome.
742    pub fn handle_event(&mut self, event: &tui_dispatch_core::EventKind) -> DebugOutcome<A> {
743        self.handle_event_internal::<()>(event, None)
744    }
745
746    /// Handle a debug event with access to state (for the state overlay).
747    pub fn handle_event_with_state<S: DebugState + 'static>(
748        &mut self,
749        event: &tui_dispatch_core::EventKind,
750        state: &S,
751    ) -> DebugOutcome<A> {
752        self.handle_event_internal(event, Some(state))
753    }
754
755    /// Check if debug layer intercepts an event, returning any side effects.
756    ///
757    /// Returns `None` if the event was not consumed, `Some(effects)` if it was.
758    pub fn intercepts_with_effects(
759        &mut self,
760        event: &tui_dispatch_core::EventKind,
761    ) -> Option<Vec<DebugSideEffect<A>>> {
762        self.intercepts_with_effects_internal::<()>(event, None)
763    }
764
765    /// Check if debug layer intercepts an event with access to app state.
766    ///
767    /// Use this to populate the state overlay when `S` is pressed.
768    pub fn intercepts_with_effects_and_state<S: DebugState + 'static>(
769        &mut self,
770        event: &tui_dispatch_core::EventKind,
771        state: &S,
772    ) -> Option<Vec<DebugSideEffect<A>>> {
773        self.intercepts_with_effects_internal(event, Some(state))
774    }
775
776    /// Check if debug layer intercepts an event with access to app state.
777    pub fn intercepts_with_state<S: DebugState + 'static>(
778        &mut self,
779        event: &tui_dispatch_core::EventKind,
780        state: &S,
781    ) -> bool {
782        self.intercepts_with_effects_internal(event, Some(state))
783            .is_some()
784    }
785
786    fn handle_event_internal<S: DebugState + 'static>(
787        &mut self,
788        event: &tui_dispatch_core::EventKind,
789        state: Option<&S>,
790    ) -> DebugOutcome<A> {
791        let effects = self.intercepts_with_effects_internal(event, state);
792        let Some(effects) = effects else {
793            return DebugOutcome::ignored();
794        };
795
796        let mut queued_actions = Vec::new();
797        for effect in effects {
798            if let DebugSideEffect::ProcessQueuedActions(actions) = effect {
799                queued_actions.extend(actions);
800            }
801        }
802
803        DebugOutcome::consumed(queued_actions)
804    }
805
806    fn intercepts_with_effects_internal<S: DebugState + 'static>(
807        &mut self,
808        event: &tui_dispatch_core::EventKind,
809        state: Option<&S>,
810    ) -> Option<Vec<DebugSideEffect<A>>> {
811        if !self.active {
812            return None;
813        }
814
815        use tui_dispatch_core::EventKind;
816
817        match event {
818            EventKind::Key(key) => self.handle_key_event(*key, state),
819            EventKind::Mouse(mouse) => {
820                if !self.freeze.enabled {
821                    return None;
822                }
823
824                // Only capture mouse when mouse_capture_enabled (toggle with 'i')
825                // When disabled, let terminal handle mouse (allows text selection)
826                if !self.freeze.mouse_capture_enabled {
827                    return None;
828                }
829
830                // Handle click for cell inspection
831                if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
832                    let effect = self.handle_action(DebugAction::InspectCell {
833                        column: mouse.column,
834                        row: mouse.row,
835                    });
836                    return Some(effect.into_iter().collect());
837                }
838
839                // Consume mouse events when capturing
840                Some(vec![])
841            }
842            EventKind::Scroll {
843                delta,
844                column,
845                row,
846                modifiers,
847            } => {
848                if !self.freeze.enabled {
849                    return None;
850                }
851
852                match self.freeze.overlay.as_ref() {
853                    Some(DebugOverlay::ActionLog(_)) => {
854                        let action = if *delta > 0 {
855                            DebugAction::ActionLogScrollUp
856                        } else {
857                            DebugAction::ActionLogScrollDown
858                        };
859                        self.handle_action(action);
860                    }
861                    Some(DebugOverlay::State(_)) => {
862                        self.sync_state_tree_state();
863                        let nodes = &self.state_tree_nodes;
864                        let selected_id = self.state_tree_selected.as_ref();
865                        let expanded_ids = &self.state_tree_expanded;
866                        let style = self.state_tree_style();
867                        let props =
868                            Self::build_state_tree_props(nodes, selected_id, expanded_ids, style);
869                        let actions: Vec<_> = self
870                            .state_tree_view
871                            .handle_event(
872                                &EventKind::Scroll {
873                                    delta: *delta,
874                                    column: *column,
875                                    row: *row,
876                                    modifiers: *modifiers,
877                                },
878                                props,
879                            )
880                            .into_iter()
881                            .collect();
882                        if !actions.is_empty() {
883                            self.apply_state_tree_actions(actions);
884                        }
885                    }
886                    Some(DebugOverlay::Inspect(table)) => {
887                        if *delta > 0 {
888                            self.scroll_table_up();
889                        } else {
890                            self.scroll_table_down(table.rows.len());
891                        }
892                    }
893                    Some(DebugOverlay::ActionDetail(detail)) => {
894                        let params_lines = self.detail_params_lines(detail);
895                        if *delta > 0 {
896                            self.scroll_detail_up();
897                        } else {
898                            self.scroll_detail_down(params_lines.len());
899                        }
900                    }
901                    _ => {}
902                }
903
904                Some(vec![])
905            }
906            // Don't intercept resize or tick events
907            EventKind::Resize(_, _) | EventKind::Tick => None,
908        }
909    }
910
911    /// Show state overlay using a DebugState implementor.
912    pub fn show_state_overlay<S: DebugState>(&mut self, state: &S) {
913        let table = state.build_debug_table("Application State");
914        self.set_state_overlay(table);
915    }
916
917    /// Show action log overlay.
918    pub fn show_action_log(&mut self) {
919        let overlay = ActionLogOverlay::from_log(&self.action_log, "Action Log");
920        self.freeze.set_overlay(DebugOverlay::ActionLog(overlay));
921    }
922
923    /// Queue an action to be processed when debug mode is disabled.
924    pub fn queue_action(&mut self, action: A) {
925        self.freeze.queue(action);
926    }
927
928    /// Take any queued actions (from task manager resume).
929    ///
930    /// Call this after `intercepts()` returns effects to get queued actions
931    /// that should be dispatched.
932    pub fn take_queued_actions(&mut self) -> Vec<A> {
933        std::mem::take(&mut self.freeze.queued_actions)
934    }
935
936    // =========================================================================
937    // Private helpers
938    // =========================================================================
939
940    fn set_state_overlay(&mut self, table: DebugTableOverlay) {
941        let is_new_overlay = !matches!(self.freeze.overlay, Some(DebugOverlay::State(_)));
942        if is_new_overlay {
943            self.table_scroll_offset = 0;
944            self.state_tree_view = TreeView::new();
945            self.state_tree_selected = None;
946            self.state_tree_expanded.clear();
947        }
948
949        self.state_tree_nodes = self.build_state_tree_nodes(&table);
950        if is_new_overlay {
951            self.state_tree_expanded = self
952                .state_tree_nodes
953                .iter()
954                .map(|node| node.id.clone())
955                .collect();
956        }
957        self.sync_state_tree_state();
958        self.state_snapshot = Some(table.clone());
959        self.freeze.set_overlay(DebugOverlay::State(table));
960    }
961
962    fn build_state_tree_nodes(&self, table: &DebugTableOverlay) -> Vec<TreeNode<String, String>> {
963        let mut sections: Vec<TreeNode<String, String>> = Vec::new();
964        let mut current: Option<TreeNode<String, String>> = None;
965        let mut section_index = 0usize;
966        let mut entry_index = 0usize;
967        let mut current_section_index = 0usize;
968
969        for row in &table.rows {
970            match row {
971                DebugTableRow::Section(title) => {
972                    if let Some(section) = current.take() {
973                        sections.push(section);
974                    }
975                    current_section_index = section_index;
976                    entry_index = 0;
977                    let id = format!("section:{section_index}:{title}");
978                    section_index = section_index.saturating_add(1);
979                    current = Some(TreeNode::with_children(id, title.clone(), Vec::new()));
980                }
981                DebugTableRow::Entry { key, value } => {
982                    if current.is_none() {
983                        current_section_index = section_index;
984                        entry_index = 0;
985                        let id = format!("section:{section_index}:State");
986                        section_index = section_index.saturating_add(1);
987                        current =
988                            Some(TreeNode::with_children(id, "State".to_string(), Vec::new()));
989                    }
990                    if let Some(section) = current.as_mut() {
991                        let entry_id = format!("entry:{current_section_index}:{entry_index}:{key}");
992                        entry_index = entry_index.saturating_add(1);
993                        section
994                            .children
995                            .push(TreeNode::new(entry_id, format!("{key}: {value}")));
996                    }
997                }
998            }
999        }
1000
1001        if let Some(section) = current.take() {
1002            sections.push(section);
1003        }
1004
1005        sections
1006    }
1007
1008    fn sync_state_tree_state(&mut self) {
1009        let nodes = &self.state_tree_nodes;
1010        self.state_tree_expanded
1011            .retain(|id| Self::tree_contains_id(nodes, id));
1012
1013        let selected_valid = self
1014            .state_tree_selected
1015            .as_deref()
1016            .map(|id| self.state_tree_contains_id(id))
1017            .unwrap_or(false);
1018
1019        if !selected_valid {
1020            self.state_tree_selected = self.state_tree_nodes.first().map(|node| node.id.clone());
1021        }
1022    }
1023
1024    fn state_tree_contains_id(&self, id: &str) -> bool {
1025        Self::tree_contains_id(&self.state_tree_nodes, id)
1026    }
1027
1028    fn tree_contains_id(nodes: &[TreeNode<String, String>], id: &str) -> bool {
1029        nodes
1030            .iter()
1031            .any(|node| node.id == id || Self::tree_contains_id(&node.children, id))
1032    }
1033
1034    fn state_tree_style(&self) -> TreeViewStyle {
1035        TreeViewStyle {
1036            base: BaseStyle {
1037                border: None,
1038                padding: Padding::default(),
1039                bg: Some(DebugStyle::overlay_bg()),
1040                fg: Some(DebugStyle::text_primary()),
1041            },
1042            selection: SelectionStyle::disabled(),
1043            scrollbar: self.component_scrollbar_style(),
1044            branches: TreeBranchStyle {
1045                mode: TreeBranchMode::Branch,
1046                connector_style: Style::default().fg(DebugStyle::text_secondary()),
1047                ..Default::default()
1048            },
1049        }
1050    }
1051
1052    fn build_state_tree_props<'a>(
1053        nodes: &'a [TreeNode<String, String>],
1054        selected_id: Option<&'a String>,
1055        expanded_ids: &'a HashSet<String>,
1056        style: TreeViewStyle,
1057    ) -> TreeViewProps<'a, String, String, StateTreeAction> {
1058        TreeViewProps {
1059            nodes,
1060            selected_id,
1061            expanded_ids,
1062            is_focused: true,
1063            style,
1064            behavior: TreeViewBehavior::default(),
1065            measure_node: None, // No fixed column width
1066            column_padding: 0,
1067            on_select: |id| state_tree_select(id),
1068            on_toggle: |id, expanded| state_tree_toggle(id, expanded),
1069            render_node: &render_state_tree_node,
1070        }
1071    }
1072
1073    fn apply_state_tree_actions(&mut self, actions: impl IntoIterator<Item = StateTreeAction>) {
1074        for action in actions {
1075            match action {
1076                StateTreeAction::Select(id) => {
1077                    self.state_tree_selected = Some(id);
1078                }
1079                StateTreeAction::Toggle(id, expand) => {
1080                    if expand {
1081                        self.state_tree_expanded.insert(id);
1082                    } else {
1083                        self.state_tree_expanded.remove(&id);
1084                    }
1085                }
1086            }
1087        }
1088    }
1089
1090    fn save_state_snapshot<S: DebugState + 'static>(&mut self, state: Option<&S>) {
1091        let Some(state) = state else {
1092            self.freeze
1093                .set_message("State unavailable: call render_state() first");
1094            return;
1095        };
1096
1097        let path = self.state_snapshot_path();
1098
1099        let result = if let Some(snapshotter) = self.state_snapshotter.as_ref() {
1100            snapshotter(state as &dyn Any, &path)
1101        } else {
1102            let snapshot = DebugStateSnapshot::from_state(state, "Application State");
1103            crate::save_json(&path, &snapshot)
1104        };
1105
1106        match result {
1107            Ok(()) => self
1108                .freeze
1109                .set_message(format!("Saved state: {}", path.display())),
1110            Err(err) => self
1111                .freeze
1112                .set_message(format!("State save failed: {err:?}")),
1113        }
1114    }
1115
1116    fn state_snapshot_path(&self) -> PathBuf {
1117        let timestamp = SystemTime::now()
1118            .duration_since(UNIX_EPOCH)
1119            .unwrap_or_default()
1120            .as_millis();
1121        PathBuf::from(format!("debug-state-{timestamp}.json"))
1122    }
1123
1124    #[allow(dead_code)]
1125    fn update_table_scroll(&mut self, table: &DebugTableOverlay, visible_rows: usize) {
1126        self.table_page_size = visible_rows.max(1);
1127        let max_offset = table.rows.len().saturating_sub(visible_rows);
1128        self.table_scroll_offset = self.table_scroll_offset.min(max_offset);
1129    }
1130
1131    fn overlay_modal_area(&self, app_area: Rect) -> Rect {
1132        let modal_width = (app_area.width * 80 / 100)
1133            .clamp(40, 160)
1134            .min(app_area.width);
1135        let modal_height = (app_area.height * 80 / 100)
1136            .clamp(12, 50)
1137            .min(app_area.height);
1138        centered_rect(modal_width, modal_height, app_area)
1139    }
1140
1141    fn overlay_modal_style(&self) -> ModalStyle {
1142        ModalStyle {
1143            dim_factor: 0.0,
1144            base: BaseStyle {
1145                border: None,
1146                padding: Padding::default(), // No outer padding - title/footer at edges
1147                bg: Some(DebugStyle::overlay_bg()),
1148                fg: None,
1149            },
1150        }
1151    }
1152
1153    fn render_overlay_container<F>(
1154        &self,
1155        frame: &mut Frame,
1156        app_area: Rect,
1157        title: &str,
1158        hints: Option<ModalHints>,
1159        footer_center_items: Option<Vec<StatusBarItem<'static>>>,
1160        mut render_body: F,
1161    ) where
1162        F: FnMut(&mut Frame, Rect),
1163    {
1164        let modal_area = self.overlay_modal_area(app_area);
1165        let mut modal = Modal::new();
1166        let mut render_content = |frame: &mut Frame, content_area: Rect| {
1167            if content_area.height == 0 || content_area.width == 0 {
1168                return;
1169            }
1170
1171            // Render title bar (top)
1172            let content_area = self.render_overlay_title(frame, content_area, title);
1173            if content_area.height == 0 || content_area.width == 0 {
1174                return;
1175            }
1176
1177            // Render footer bar (bottom) if hints provided
1178            let content_area = if let Some(hints) = hints {
1179                self.render_overlay_footer(
1180                    frame,
1181                    content_area,
1182                    hints,
1183                    footer_center_items.as_deref(),
1184                )
1185            } else {
1186                content_area
1187            };
1188            if content_area.height == 0 || content_area.width == 0 {
1189                return;
1190            }
1191
1192            // Apply inner padding to body content only (not title/footer)
1193            let body_area = Rect {
1194                x: content_area.x.saturating_add(1),
1195                y: content_area.y,
1196                width: content_area.width.saturating_sub(2),
1197                height: content_area.height,
1198            };
1199            if body_area.width == 0 {
1200                return;
1201            }
1202
1203            render_body(frame, body_area);
1204        };
1205
1206        modal.render(
1207            frame,
1208            app_area,
1209            ModalProps {
1210                is_open: true,
1211                is_focused: false,
1212                area: modal_area,
1213                style: self.overlay_modal_style(),
1214                behavior: ModalBehavior::default(),
1215                on_close: || (),
1216                render_content: &mut render_content,
1217            },
1218        );
1219    }
1220
1221    fn render_overlay_title(&self, frame: &mut Frame, area: Rect, title: &str) -> Rect {
1222        if area.height == 0 || area.width == 0 {
1223            return area;
1224        }
1225
1226        use ratatui::text::Span;
1227
1228        let title_style = Style::default()
1229            .fg(DebugStyle::accent())
1230            .add_modifier(Modifier::BOLD);
1231        let title_items = [StatusBarItem::span(Span::styled(
1232            format!(" {title} "),
1233            title_style,
1234        ))];
1235        let title_bar_style = StatusBarStyle {
1236            base: BaseStyle {
1237                border: None,
1238                padding: Padding::default(),
1239                bg: None,
1240                fg: None,
1241            },
1242            text: Style::default().fg(DebugStyle::accent()),
1243            hint_key: title_style,
1244            hint_label: Style::default().fg(DebugStyle::accent()),
1245            separator: Style::default().fg(DebugStyle::accent()),
1246        };
1247        let title_area = Rect { height: 1, ..area };
1248
1249        let mut status_bar = StatusBar::new();
1250        <StatusBar as tui_dispatch_core::Component<()>>::render(
1251            &mut status_bar,
1252            frame,
1253            title_area,
1254            StatusBarProps {
1255                left: StatusBarSection::empty(),
1256                center: StatusBarSection::items(&title_items),
1257                right: StatusBarSection::empty(),
1258                style: title_bar_style,
1259                is_focused: false,
1260            },
1261        );
1262
1263        Rect {
1264            x: area.x,
1265            y: area.y.saturating_add(title_area.height),
1266            width: area.width,
1267            height: area.height.saturating_sub(title_area.height),
1268        }
1269    }
1270
1271    /// Render a modal footer status bar with hints.
1272    /// Returns the remaining content area (above the footer).
1273    fn render_overlay_footer(
1274        &self,
1275        frame: &mut Frame,
1276        area: Rect,
1277        hints: ModalHints,
1278        center_items: Option<&[StatusBarItem<'static>]>,
1279    ) -> Rect {
1280        if area.height < 2 || area.width == 0 {
1281            return area;
1282        }
1283
1284        let footer_area = Rect {
1285            x: area.x,
1286            y: area.y.saturating_add(area.height.saturating_sub(1)),
1287            width: area.width,
1288            height: 1,
1289        };
1290
1291        // Key style: bold, dark text on muted background
1292        let key_style = Style::default()
1293            .fg(DebugStyle::bg_deep())
1294            .bg(DebugStyle::text_secondary())
1295            .add_modifier(Modifier::BOLD);
1296        let label_style = Style::default().fg(DebugStyle::text_secondary());
1297
1298        // Build left items from hints
1299        let mut left_items: Vec<StatusBarItem<'static>> = Vec::new();
1300        for hint in hints.left {
1301            left_items.push(StatusBarItem::span(Span::styled(
1302                format!(" {} ", hint.key),
1303                key_style,
1304            )));
1305            left_items.push(StatusBarItem::span(Span::styled(
1306                format!(" {} ", hint.label),
1307                label_style,
1308            )));
1309        }
1310
1311        // Build right items from hints
1312        let mut right_items: Vec<StatusBarItem<'static>> = Vec::new();
1313        for hint in hints.right {
1314            right_items.push(StatusBarItem::span(Span::styled(
1315                format!(" {} ", hint.key),
1316                key_style,
1317            )));
1318            right_items.push(StatusBarItem::span(Span::styled(
1319                format!(" {} ", hint.label),
1320                label_style,
1321            )));
1322        }
1323
1324        let footer_style = StatusBarStyle {
1325            base: BaseStyle {
1326                border: None,
1327                padding: Padding::default(),
1328                bg: Some(DebugStyle::overlay_bg_dark()),
1329                fg: None,
1330            },
1331            text: label_style,
1332            hint_key: key_style,
1333            hint_label: label_style,
1334            separator: label_style,
1335        };
1336
1337        let left = StatusBarSection::items(&left_items).with_separator("");
1338        let center = if let Some(items) = center_items {
1339            StatusBarSection::items(items).with_separator("")
1340        } else {
1341            StatusBarSection::empty()
1342        };
1343        let right = if right_items.is_empty() {
1344            StatusBarSection::empty()
1345        } else {
1346            StatusBarSection::items(&right_items).with_separator("")
1347        };
1348
1349        let mut status_bar = StatusBar::new();
1350        <StatusBar as tui_dispatch_core::Component<()>>::render(
1351            &mut status_bar,
1352            frame,
1353            footer_area,
1354            StatusBarProps {
1355                left,
1356                center,
1357                right,
1358                style: footer_style,
1359                is_focused: false,
1360            },
1361        );
1362
1363        // Return area above footer
1364        Rect {
1365            x: area.x,
1366            y: area.y,
1367            width: area.width,
1368            height: area.height.saturating_sub(1),
1369        }
1370    }
1371
1372    fn component_scrollbar_style(&self) -> ComponentScrollbarStyle {
1373        let style = &self.style.scrollbar;
1374        ComponentScrollbarStyle {
1375            thumb: style.thumb,
1376            track: style.track,
1377            begin: style.begin,
1378            end: style.end,
1379            thumb_symbol: style.thumb_symbol,
1380            track_symbol: style.track_symbol,
1381            begin_symbol: style.begin_symbol,
1382            end_symbol: style.end_symbol,
1383        }
1384    }
1385
1386    fn table_page_size_value(&self) -> usize {
1387        self.table_page_size.max(1)
1388    }
1389
1390    fn table_max_offset(&self, rows_len: usize) -> usize {
1391        rows_len.saturating_sub(self.table_page_size_value())
1392    }
1393
1394    fn scroll_table_up(&mut self) {
1395        self.table_scroll_offset = self.table_scroll_offset.saturating_sub(1);
1396    }
1397
1398    fn scroll_table_down(&mut self, rows_len: usize) {
1399        let max_offset = self.table_max_offset(rows_len);
1400        self.table_scroll_offset = (self.table_scroll_offset + 1).min(max_offset);
1401    }
1402
1403    fn scroll_table_to_top(&mut self) {
1404        self.table_scroll_offset = 0;
1405    }
1406
1407    fn scroll_table_to_bottom(&mut self, rows_len: usize) {
1408        self.table_scroll_offset = self.table_max_offset(rows_len);
1409    }
1410
1411    fn scroll_table_page_up(&mut self) {
1412        let page_size = self.table_page_size_value();
1413        self.table_scroll_offset = self.table_scroll_offset.saturating_sub(page_size);
1414    }
1415
1416    fn scroll_table_page_down(&mut self, rows_len: usize) {
1417        let page_size = self.table_page_size_value();
1418        let max_offset = self.table_max_offset(rows_len);
1419        self.table_scroll_offset = (self.table_scroll_offset + page_size).min(max_offset);
1420    }
1421
1422    fn detail_page_size_value(&self) -> usize {
1423        self.detail_page_size.max(1)
1424    }
1425
1426    fn detail_max_offset(&self, rows_len: usize) -> usize {
1427        rows_len.saturating_sub(self.detail_page_size_value())
1428    }
1429
1430    fn scroll_detail_up(&mut self) {
1431        self.detail_scroll_offset = self.detail_scroll_offset.saturating_sub(1);
1432    }
1433
1434    fn scroll_detail_down(&mut self, rows_len: usize) {
1435        let max_offset = self.detail_max_offset(rows_len);
1436        self.detail_scroll_offset = (self.detail_scroll_offset + 1).min(max_offset);
1437    }
1438
1439    fn scroll_detail_to_top(&mut self) {
1440        self.detail_scroll_offset = 0;
1441    }
1442
1443    fn scroll_detail_to_bottom(&mut self, rows_len: usize) {
1444        self.detail_scroll_offset = self.detail_max_offset(rows_len);
1445    }
1446
1447    fn scroll_detail_page_up(&mut self) {
1448        let page_size = self.detail_page_size_value();
1449        self.detail_scroll_offset = self.detail_scroll_offset.saturating_sub(page_size);
1450    }
1451
1452    fn scroll_detail_page_down(&mut self, rows_len: usize) {
1453        let page_size = self.detail_page_size_value();
1454        let max_offset = self.detail_max_offset(rows_len);
1455        self.detail_scroll_offset = (self.detail_scroll_offset + page_size).min(max_offset);
1456    }
1457
1458    fn handle_table_scroll_key(&mut self, key: KeyCode, rows_len: usize) -> bool {
1459        match key {
1460            KeyCode::Char('j') | KeyCode::Down => {
1461                self.scroll_table_down(rows_len);
1462                true
1463            }
1464            KeyCode::Char('k') | KeyCode::Up => {
1465                self.scroll_table_up();
1466                true
1467            }
1468            KeyCode::Char('g') => {
1469                self.scroll_table_to_top();
1470                true
1471            }
1472            KeyCode::Char('G') => {
1473                self.scroll_table_to_bottom(rows_len);
1474                true
1475            }
1476            KeyCode::PageDown => {
1477                self.scroll_table_page_down(rows_len);
1478                true
1479            }
1480            KeyCode::PageUp => {
1481                self.scroll_table_page_up();
1482                true
1483            }
1484            _ => false,
1485        }
1486    }
1487
1488    fn handle_detail_scroll_key(&mut self, key: KeyCode, rows_len: usize) -> bool {
1489        match key {
1490            KeyCode::Char('j') | KeyCode::Down => {
1491                self.scroll_detail_down(rows_len);
1492                true
1493            }
1494            KeyCode::Char('k') | KeyCode::Up => {
1495                self.scroll_detail_up();
1496                true
1497            }
1498            KeyCode::Char('g') => {
1499                self.scroll_detail_to_top();
1500                true
1501            }
1502            KeyCode::Char('G') => {
1503                self.scroll_detail_to_bottom(rows_len);
1504                true
1505            }
1506            KeyCode::PageDown => {
1507                self.scroll_detail_page_down(rows_len);
1508                true
1509            }
1510            KeyCode::PageUp => {
1511                self.scroll_detail_page_up();
1512                true
1513            }
1514            _ => false,
1515        }
1516    }
1517
1518    fn handle_key_event<S: DebugState + 'static>(
1519        &mut self,
1520        key: KeyEvent,
1521        state: Option<&S>,
1522    ) -> Option<Vec<DebugSideEffect<A>>> {
1523        // Toggle key always works (even when disabled)
1524        if key.code == self.toggle_key && key.modifiers.is_empty() {
1525            let effect = self.toggle();
1526            return Some(effect.into_iter().collect());
1527        }
1528
1529        // Other commands only work when enabled
1530        if !self.freeze.enabled {
1531            return None;
1532        }
1533
1534        // Handle action-log specific search/navigation first.
1535        // We intentionally do a non-borrowing presence check before taking a mutable
1536        // overlay reference, so we can still call other `&mut self` helpers in this
1537        // function without extending the `log` borrow across the whole match block.
1538        if matches!(self.freeze.overlay, Some(DebugOverlay::ActionLog(_))) {
1539            let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay else {
1540                return Some(vec![]);
1541            };
1542
1543            if log.search_input_active {
1544                let is_text_input_char = !key.modifiers.contains(KeyModifiers::CONTROL)
1545                    && !key.modifiers.contains(KeyModifiers::ALT);
1546                match key.code {
1547                    KeyCode::Esc | KeyCode::Enter => {
1548                        log.search_input_active = false;
1549                        return Some(vec![]);
1550                    }
1551                    KeyCode::Backspace => {
1552                        if !log.pop_search_char() {
1553                            log.search_input_active = false;
1554                        }
1555                        return Some(vec![]);
1556                    }
1557                    KeyCode::Char(c) if is_text_input_char => {
1558                        log.push_search_char(c);
1559                        return Some(vec![]);
1560                    }
1561                    _ => {
1562                        return Some(vec![]);
1563                    }
1564                }
1565            }
1566
1567            // Backspace closes this overlay when not editing search query.
1568            if key.code == KeyCode::Backspace {
1569                self.handle_action(DebugAction::CloseOverlay);
1570                return Some(vec![]);
1571            }
1572
1573            if key.code == KeyCode::Char('/') {
1574                log.search_input_active = true;
1575                return Some(vec![]);
1576            }
1577
1578            // Crossterm normalizes Shift+n to `Char('N')`.
1579            let prev_match = key.code == KeyCode::Char('N');
1580            if prev_match {
1581                log.search_prev();
1582                return Some(vec![]);
1583            }
1584
1585            if key.code == KeyCode::Char('n') {
1586                log.search_next();
1587                return Some(vec![]);
1588            }
1589
1590            let action = match key.code {
1591                KeyCode::Char('j') | KeyCode::Down => Some(DebugAction::ActionLogScrollDown),
1592                KeyCode::Char('k') | KeyCode::Up => Some(DebugAction::ActionLogScrollUp),
1593                KeyCode::Char('g') => Some(DebugAction::ActionLogScrollTop),
1594                KeyCode::Char('G') => Some(DebugAction::ActionLogScrollBottom),
1595                KeyCode::PageDown => Some(DebugAction::ActionLogPageDown),
1596                KeyCode::PageUp => Some(DebugAction::ActionLogPageUp),
1597                KeyCode::Enter => Some(DebugAction::ActionLogShowDetail),
1598                _ => None,
1599            };
1600            if let Some(action) = action {
1601                self.handle_action(action);
1602                return Some(vec![]);
1603            }
1604        }
1605
1606        // Esc toggles debug mode when action-log search input did not consume it.
1607        if key.code == KeyCode::Esc {
1608            let effect = self.toggle();
1609            return Some(effect.into_iter().collect());
1610        }
1611
1612        match key.code {
1613            KeyCode::Char('b') | KeyCode::Char('B') => {
1614                self.banner_position = self.banner_position.toggle();
1615                self.freeze.request_capture();
1616                return Some(vec![]);
1617            }
1618            KeyCode::Char('s') | KeyCode::Char('S') => {
1619                if matches!(self.freeze.overlay, Some(DebugOverlay::State(_))) {
1620                    self.freeze.clear_overlay();
1621                } else if let Some(state) = state {
1622                    let table = state.build_debug_table("Application State");
1623                    self.set_state_overlay(table);
1624                } else if let Some(ref table) = self.state_snapshot {
1625                    self.set_state_overlay(table.clone());
1626                } else {
1627                    let table = DebugTableBuilder::new()
1628                        .section("State")
1629                        .entry(
1630                            "hint",
1631                            "Press 's' after providing state via render_with_state() or show_state_overlay()",
1632                        )
1633                        .finish("Application State");
1634                    self.freeze.set_overlay(DebugOverlay::State(table));
1635                }
1636                return Some(vec![]);
1637            }
1638            KeyCode::Char('w') | KeyCode::Char('W') => {
1639                self.save_state_snapshot(state);
1640                return Some(vec![]);
1641            }
1642            _ => {}
1643        }
1644
1645        // Handle internal debug commands (hardcoded keys).
1646        // Note: this runs after action-log search handling so text input in `/`
1647        // mode can accept letters like 'A' without toggling the overlay.
1648        let action = match key.code {
1649            KeyCode::Char('a') | KeyCode::Char('A') => Some(DebugAction::ToggleActionLog),
1650            KeyCode::Char('y') | KeyCode::Char('Y') => Some(DebugAction::CopyFrame),
1651            KeyCode::Char('i') | KeyCode::Char('I') => Some(DebugAction::ToggleMouseCapture),
1652            KeyCode::Char('q') | KeyCode::Char('Q') => Some(DebugAction::CloseOverlay),
1653            _ => None,
1654        };
1655
1656        if let Some(action) = action {
1657            let effect = self.handle_action(action);
1658            return Some(effect.into_iter().collect());
1659        }
1660
1661        // Handle remaining overlay-specific navigation.
1662        match &self.freeze.overlay {
1663            Some(DebugOverlay::State(_)) => {
1664                // Backspace closes this overlay
1665                if key.code == KeyCode::Backspace {
1666                    self.handle_action(DebugAction::CloseOverlay);
1667                    return Some(vec![]);
1668                }
1669                self.sync_state_tree_state();
1670                let nodes = &self.state_tree_nodes;
1671                let selected_id = self.state_tree_selected.as_ref();
1672                let expanded_ids = &self.state_tree_expanded;
1673                let style = self.state_tree_style();
1674                let props = Self::build_state_tree_props(nodes, selected_id, expanded_ids, style);
1675                let actions: Vec<_> = self
1676                    .state_tree_view
1677                    .handle_event(&EventKind::Key(key), props)
1678                    .into_iter()
1679                    .collect();
1680                if !actions.is_empty() {
1681                    self.apply_state_tree_actions(actions);
1682                    return Some(vec![]);
1683                }
1684            }
1685            Some(DebugOverlay::Inspect(table)) => {
1686                // Backspace closes this overlay
1687                if key.code == KeyCode::Backspace {
1688                    self.handle_action(DebugAction::CloseOverlay);
1689                    return Some(vec![]);
1690                }
1691                if self.handle_table_scroll_key(key.code, table.rows.len()) {
1692                    return Some(vec![]);
1693                }
1694            }
1695            Some(DebugOverlay::ActionDetail(detail)) => {
1696                // Back to action log on Esc, Backspace, or Enter
1697                if matches!(key.code, KeyCode::Esc | KeyCode::Backspace | KeyCode::Enter) {
1698                    self.handle_action(DebugAction::ActionLogBackToList);
1699                    return Some(vec![]);
1700                }
1701
1702                let params_lines = self.detail_params_lines(detail);
1703                if self.handle_detail_scroll_key(key.code, params_lines.len()) {
1704                    return Some(vec![]);
1705                }
1706            }
1707            _ => {}
1708        }
1709
1710        // Consume all key events when debug is enabled
1711        Some(vec![])
1712    }
1713
1714    fn toggle(&mut self) -> Option<DebugSideEffect<A>> {
1715        if self.freeze.enabled {
1716            // Disable: resume tasks/subs
1717            #[cfg(feature = "subscriptions")]
1718            if let Some(ref handle) = self.sub_handle {
1719                handle.resume();
1720            }
1721
1722            #[cfg(feature = "tasks")]
1723            let task_queued = if let Some(ref handle) = self.task_handle {
1724                handle.resume()
1725            } else {
1726                vec![]
1727            };
1728            #[cfg(not(feature = "tasks"))]
1729            let task_queued: Vec<A> = vec![];
1730
1731            let queued = self.freeze.take_queued();
1732            self.freeze.disable();
1733            self.state_snapshot = None;
1734            self.state_tree_nodes.clear();
1735            self.state_tree_selected = None;
1736            self.state_tree_expanded.clear();
1737            self.state_tree_view = TreeView::new();
1738            self.table_scroll_offset = 0;
1739            self.table_page_size = 1;
1740            self.detail_scroll_offset = 0;
1741            self.detail_page_size = 1;
1742
1743            // Combine queued actions from freeze and task manager
1744            let mut all_queued = queued;
1745            all_queued.extend(task_queued);
1746
1747            if all_queued.is_empty() {
1748                None
1749            } else {
1750                Some(DebugSideEffect::ProcessQueuedActions(all_queued))
1751            }
1752        } else {
1753            // Enable: pause tasks/subs
1754            #[cfg(feature = "tasks")]
1755            if let Some(ref handle) = self.task_handle {
1756                handle.pause();
1757            }
1758            #[cfg(feature = "subscriptions")]
1759            if let Some(ref handle) = self.sub_handle {
1760                handle.pause();
1761            }
1762            self.freeze.enable();
1763            self.state_snapshot = None;
1764            self.state_tree_nodes.clear();
1765            self.state_tree_selected = None;
1766            self.state_tree_expanded.clear();
1767            self.state_tree_view = TreeView::new();
1768            self.table_scroll_offset = 0;
1769            self.table_page_size = 1;
1770            self.detail_scroll_offset = 0;
1771            self.detail_page_size = 1;
1772            None
1773        }
1774    }
1775
1776    fn handle_action(&mut self, action: DebugAction) -> Option<DebugSideEffect<A>> {
1777        match action {
1778            DebugAction::Toggle => self.toggle(),
1779            DebugAction::CopyFrame => {
1780                let text = &self.freeze.snapshot_text;
1781                // Use OSC52 escape sequence to copy to clipboard
1782                let encoded = BASE64_STANDARD.encode(text);
1783                print!("\x1b]52;c;{}\x07", encoded);
1784                std::io::stdout().flush().ok();
1785                self.freeze.set_message("Copied to clipboard");
1786                None
1787            }
1788            DebugAction::ToggleState => {
1789                if matches!(self.freeze.overlay, Some(DebugOverlay::State(_))) {
1790                    self.freeze.clear_overlay();
1791                } else if let Some(ref table) = self.state_snapshot {
1792                    self.set_state_overlay(table.clone());
1793                } else {
1794                    // Show placeholder - user should call show_state_overlay()
1795                    let table = DebugTableBuilder::new()
1796                        .section("State")
1797                        .entry(
1798                            "hint",
1799                            "Press 's' after providing state via render_with_state() or show_state_overlay()",
1800                        )
1801                        .finish("Application State");
1802                    self.freeze.set_overlay(DebugOverlay::State(table));
1803                }
1804                None
1805            }
1806            DebugAction::ToggleActionLog => {
1807                if matches!(self.freeze.overlay, Some(DebugOverlay::ActionLog(_))) {
1808                    self.freeze.clear_overlay();
1809                } else {
1810                    self.show_action_log();
1811                }
1812                None
1813            }
1814            DebugAction::ActionLogScrollUp => {
1815                if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
1816                    log.scroll_up();
1817                }
1818                None
1819            }
1820            DebugAction::ActionLogScrollDown => {
1821                if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
1822                    log.scroll_down();
1823                }
1824                None
1825            }
1826            DebugAction::ActionLogScrollTop => {
1827                if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
1828                    log.scroll_to_top();
1829                }
1830                None
1831            }
1832            DebugAction::ActionLogScrollBottom => {
1833                if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
1834                    log.scroll_to_bottom();
1835                }
1836                None
1837            }
1838            DebugAction::ActionLogPageUp => {
1839                if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
1840                    log.page_up(10);
1841                }
1842                None
1843            }
1844            DebugAction::ActionLogPageDown => {
1845                if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
1846                    log.page_down(10);
1847                }
1848                None
1849            }
1850            DebugAction::ActionLogShowDetail => {
1851                if let Some(DebugOverlay::ActionLog(ref log)) = self.freeze.overlay {
1852                    if let Some(detail) = log.selected_detail() {
1853                        self.detail_scroll_offset = 0;
1854                        self.detail_page_size = 1;
1855                        self.freeze.set_overlay(DebugOverlay::ActionDetail(detail));
1856                    }
1857                }
1858                None
1859            }
1860            DebugAction::ActionLogBackToList => {
1861                // Go back to action log from detail view
1862                if matches!(self.freeze.overlay, Some(DebugOverlay::ActionDetail(_))) {
1863                    self.show_action_log();
1864                }
1865                None
1866            }
1867            DebugAction::ToggleMouseCapture => {
1868                self.freeze.toggle_mouse_capture();
1869                None
1870            }
1871            DebugAction::InspectCell { column, row } => {
1872                if let Some(ref snapshot) = self.freeze.snapshot {
1873                    let overlay = self.build_inspect_overlay(column, row, snapshot);
1874                    self.table_scroll_offset = 0;
1875                    self.freeze.set_overlay(DebugOverlay::Inspect(overlay));
1876                }
1877                self.freeze.mouse_capture_enabled = false;
1878                None
1879            }
1880            DebugAction::CloseOverlay => {
1881                self.freeze.clear_overlay();
1882                None
1883            }
1884            DebugAction::RequestCapture => {
1885                self.freeze.request_capture();
1886                None
1887            }
1888        }
1889    }
1890
1891    fn split_for_banner(&self, area: Rect) -> (Rect, Rect) {
1892        let banner_height = area.height.min(1);
1893        match self.banner_position {
1894            BannerPosition::Bottom => {
1895                let app_area = Rect {
1896                    height: area.height.saturating_sub(banner_height),
1897                    ..area
1898                };
1899                let banner_area = Rect {
1900                    y: area.y.saturating_add(app_area.height),
1901                    height: banner_height,
1902                    ..area
1903                };
1904                (app_area, banner_area)
1905            }
1906            BannerPosition::Top => {
1907                let banner_area = Rect {
1908                    y: area.y,
1909                    height: banner_height,
1910                    ..area
1911                };
1912                let app_area = Rect {
1913                    y: area.y.saturating_add(banner_height),
1914                    height: area.height.saturating_sub(banner_height),
1915                    ..area
1916                };
1917                (app_area, banner_area)
1918            }
1919        }
1920    }
1921
1922    fn render_debug_overlay(&mut self, frame: &mut Frame, app_area: Rect, banner_area: Rect) {
1923        let overlay = self.freeze.overlay.clone();
1924
1925        // Only dim when there's an overlay open
1926        if let Some(ref overlay) = overlay {
1927            dim_buffer(frame.buffer_mut(), self.style.dim_factor);
1928
1929            match overlay {
1930                DebugOverlay::Inspect(table) => {
1931                    self.render_table_modal(frame, app_area, table);
1932                }
1933                DebugOverlay::State(table) => {
1934                    self.render_state_tree_modal(frame, app_area, table);
1935                }
1936                DebugOverlay::ActionLog(log) => {
1937                    self.render_action_log_modal(frame, app_area, log);
1938                }
1939                DebugOverlay::ActionDetail(detail) => {
1940                    self.render_action_detail_modal(frame, app_area, detail);
1941                }
1942            }
1943        }
1944
1945        // Render banner
1946        self.render_banner(frame, banner_area);
1947    }
1948
1949    fn render_banner(&self, frame: &mut Frame, banner_area: Rect) {
1950        if banner_area.height == 0 {
1951            return;
1952        }
1953
1954        use ratatui::text::Span;
1955
1956        let keys = &self.style.key_styles;
1957        let toggle_key_str = format_key(self.toggle_key);
1958        let label_style = self.style.label_style;
1959        let value_style = self.style.value_style;
1960
1961        let mut left_items: Vec<StatusBarItem<'static>> = Vec::new();
1962        left_items.push(StatusBarItem::span(Span::styled(
1963            " DEBUG ",
1964            self.style.title_style,
1965        )));
1966        left_items.push(StatusBarItem::text(" "));
1967
1968        let push_item =
1969            |items: &mut Vec<StatusBarItem<'static>>, key: &str, label: &str, key_style: Style| {
1970                items.push(StatusBarItem::span(Span::styled(
1971                    format!(" {key} "),
1972                    key_style,
1973                )));
1974                items.push(StatusBarItem::span(Span::styled(
1975                    format!(" {label} "),
1976                    label_style,
1977                )));
1978            };
1979
1980        push_item(&mut left_items, &toggle_key_str, "resume", keys.toggle);
1981        push_item(&mut left_items, "a", "actions", keys.actions);
1982        push_item(&mut left_items, "s", "state", keys.state);
1983        push_item(
1984            &mut left_items,
1985            "b",
1986            self.banner_position.label(),
1987            keys.actions,
1988        );
1989        push_item(&mut left_items, "y", "copy", keys.copy);
1990
1991        if self.freeze.mouse_capture_enabled {
1992            push_item(&mut left_items, "click", "inspect", keys.mouse);
1993        } else {
1994            push_item(&mut left_items, "i", "mouse", keys.mouse);
1995        }
1996
1997        let mut right_items: Vec<StatusBarItem<'static>> = Vec::new();
1998        if let Some(ref msg) = self.freeze.message {
1999            right_items.push(StatusBarItem::span(Span::styled(
2000                format!(" {msg} "),
2001                value_style,
2002            )));
2003        }
2004
2005        let style = StatusBarStyle {
2006            base: BaseStyle {
2007                border: None,
2008                padding: Padding::default(),
2009                bg: self.style.banner_bg.bg,
2010                fg: None,
2011            },
2012            text: label_style,
2013            hint_key: keys.toggle,
2014            hint_label: label_style,
2015            separator: label_style,
2016        };
2017
2018        let left = StatusBarSection::items(&left_items).with_separator("");
2019        let right = if right_items.is_empty() {
2020            StatusBarSection::empty()
2021        } else {
2022            StatusBarSection::items(&right_items).with_separator("")
2023        };
2024
2025        let mut status_bar = StatusBar::new();
2026        <StatusBar as tui_dispatch_core::Component<()>>::render(
2027            &mut status_bar,
2028            frame,
2029            banner_area,
2030            StatusBarProps {
2031                left,
2032                center: StatusBarSection::empty(),
2033                right,
2034                style,
2035                is_focused: false,
2036            },
2037        );
2038    }
2039
2040    fn render_state_tree_modal(
2041        &mut self,
2042        frame: &mut Frame,
2043        app_area: Rect,
2044        table: &DebugTableOverlay,
2045    ) {
2046        self.sync_state_tree_state();
2047
2048        // Inline the overlay container logic to avoid borrow issues
2049        let modal_area = self.overlay_modal_area(app_area);
2050        let modal_style = self.overlay_modal_style();
2051        let title = &table.title;
2052
2053        let mut modal = Modal::new();
2054        let mut render_content = |frame: &mut Frame, content_area: Rect| {
2055            if content_area.height == 0 || content_area.width == 0 {
2056                return;
2057            }
2058
2059            // Render title bar (top)
2060            let content_area = self.render_overlay_title(frame, content_area, title);
2061            if content_area.height == 0 || content_area.width == 0 {
2062                return;
2063            }
2064
2065            // Render footer bar (bottom)
2066            let content_area =
2067                self.render_overlay_footer(frame, content_area, STATE_TREE_HINTS, None);
2068            if content_area.height == 0 || content_area.width == 0 {
2069                return;
2070            }
2071
2072            // Apply inner padding to body content only (not title/footer)
2073            let body_area = Rect {
2074                x: content_area.x.saturating_add(1),
2075                y: content_area.y,
2076                width: content_area.width.saturating_sub(2),
2077                height: content_area.height,
2078            };
2079            if body_area.width == 0 {
2080                return;
2081            }
2082
2083            let nodes = &self.state_tree_nodes;
2084            let selected_id = self.state_tree_selected.as_ref();
2085            let expanded_ids = &self.state_tree_expanded;
2086            let style = self.state_tree_style();
2087            let props = Self::build_state_tree_props(nodes, selected_id, expanded_ids, style);
2088
2089            <TreeView<String> as tui_dispatch_core::Component<StateTreeAction>>::render(
2090                &mut self.state_tree_view,
2091                frame,
2092                body_area,
2093                props,
2094            );
2095        };
2096
2097        modal.render(
2098            frame,
2099            app_area,
2100            ModalProps {
2101                is_open: true,
2102                is_focused: false,
2103                area: modal_area,
2104                style: modal_style,
2105                behavior: ModalBehavior::default(),
2106                on_close: || (),
2107                render_content: &mut render_content,
2108            },
2109        );
2110    }
2111
2112    fn render_table_modal(&mut self, frame: &mut Frame, app_area: Rect, table: &DebugTableOverlay) {
2113        let scrollbar_style = self.component_scrollbar_style();
2114        let table_scroll_offset = self.table_scroll_offset;
2115
2116        self.render_overlay_container(
2117            frame,
2118            app_area,
2119            &table.title,
2120            Some(INSPECT_HINTS),
2121            None,
2122            |frame, content_area| {
2123                let mut table_area = content_area;
2124                if let Some(ref preview) = table.cell_preview {
2125                    if table_area.height > 1 {
2126                        let preview_area = Rect {
2127                            height: 1,
2128                            ..table_area
2129                        };
2130                        let preview_widget = CellPreviewWidget::new(preview)
2131                            .label_style(Style::default().fg(DebugStyle::text_secondary()))
2132                            .value_style(Style::default().fg(DebugStyle::text_primary()));
2133                        frame.render_widget(preview_widget, preview_area);
2134
2135                        table_area = Rect {
2136                            y: table_area.y.saturating_add(1),
2137                            height: table_area.height.saturating_sub(1),
2138                            ..table_area
2139                        };
2140                    }
2141                }
2142
2143                if table_area.height == 0 || table_area.width == 0 {
2144                    return;
2145                }
2146
2147                use ratatui::text::{Line, Span};
2148                use ratatui::widgets::Paragraph;
2149
2150                let header_area = Rect {
2151                    height: 1,
2152                    ..table_area
2153                };
2154                let rows_area = Rect {
2155                    y: table_area.y.saturating_add(1),
2156                    height: table_area.height.saturating_sub(1),
2157                    ..table_area
2158                };
2159
2160                let style = DebugTableStyle::default();
2161                let show_scrollbar = rows_area.height > 0
2162                    && table.rows.len() > rows_area.height as usize
2163                    && table_area.width > 1;
2164                let text_width = if show_scrollbar {
2165                    table_area.width.saturating_sub(1)
2166                } else {
2167                    table_area.width
2168                } as usize;
2169
2170                if text_width == 0 {
2171                    return;
2172                }
2173
2174                let max_key_len = table
2175                    .rows
2176                    .iter()
2177                    .filter_map(|row| match row {
2178                        super::table::DebugTableRow::Entry { key, .. } => Some(key.chars().count()),
2179                        super::table::DebugTableRow::Section(_) => None,
2180                    })
2181                    .max()
2182                    .unwrap_or(0);
2183                let max_label = text_width.saturating_sub(8).max(10);
2184                let label_width = (max_key_len + 2).clamp(12, 30).min(max_label);
2185                let value_width = text_width.saturating_sub(label_width + 2).max(1);
2186
2187                let header_line = Line::from(vec![
2188                    Span::styled(pad_text("Field", label_width), style.header),
2189                    Span::styled("  ", style.header),
2190                    Span::styled(pad_text("Value", value_width), style.header),
2191                ]);
2192                frame.render_widget(Paragraph::new(header_line), header_area);
2193
2194                if rows_area.height == 0 {
2195                    return;
2196                }
2197
2198                let syntax_style = DebugSyntaxStyle::with_base(style.value);
2199                let mut rows = Vec::new();
2200                let mut entry_index = 0usize;
2201                for row in table.rows.iter() {
2202                    match row {
2203                        super::table::DebugTableRow::Section(title) => {
2204                            entry_index = 0;
2205                            let mut text = format!(" {title} ");
2206                            text = truncate_with_ellipsis(&text, text_width);
2207                            text = pad_text(&text, text_width);
2208                            rows.push(Line::from(vec![Span::styled(text, style.section)]));
2209                        }
2210                        super::table::DebugTableRow::Entry { key, value } => {
2211                            let row_style = if entry_index % 2 == 0 {
2212                                style.row_styles.0
2213                            } else {
2214                                style.row_styles.1
2215                            };
2216                            entry_index = entry_index.saturating_add(1);
2217                            let key_text = pad_text(key, label_width);
2218                            let mut value_text = truncate_with_ellipsis(value, value_width);
2219                            value_text = pad_text(&value_text, value_width);
2220
2221                            let mut value_spans: Vec<Span<'static>> =
2222                                debug_spans(&value_text, &syntax_style)
2223                                    .into_iter()
2224                                    .map(|span| span.patch_style(row_style))
2225                                    .collect();
2226
2227                            let mut spans = vec![
2228                                Span::styled(key_text, style.key).patch_style(row_style),
2229                                Span::styled("  ", row_style),
2230                            ];
2231                            spans.append(&mut value_spans);
2232
2233                            rows.push(Line::from(spans));
2234                        }
2235                    }
2236                }
2237
2238                // Note: page size update removed to avoid borrow issues
2239                let scroll_style = ScrollViewStyle {
2240                    base: BaseStyle {
2241                        border: None,
2242                        padding: Padding::default(),
2243                        bg: None,
2244                        fg: None,
2245                    },
2246                    scrollbar: scrollbar_style.clone(),
2247                };
2248                let scroller = LinesScroller::new(&rows);
2249                let mut scroll_view = ScrollView::new();
2250                <ScrollView as tui_dispatch_core::Component<()>>::render(
2251                    &mut scroll_view,
2252                    frame,
2253                    rows_area,
2254                    ScrollViewProps {
2255                        content_height: scroller.content_height(),
2256                        scroll_offset: table_scroll_offset,
2257                        is_focused: true,
2258                        style: scroll_style,
2259                        behavior: ScrollViewBehavior::default(),
2260                        on_scroll: |_| (),
2261                        render_content: &mut scroller.renderer(),
2262                    },
2263                );
2264            },
2265        );
2266    }
2267
2268    fn render_action_log_modal(&self, frame: &mut Frame, app_area: Rect, log: &ActionLogOverlay) {
2269        let entry_count = log.entries.len();
2270        let filter_active = log.has_search_query();
2271        let filtered_count = if filter_active {
2272            log.search_match_count()
2273        } else {
2274            entry_count
2275        };
2276        let title = if log.has_search_query() {
2277            format!("{} ({} / {} shown)", log.title, filtered_count, entry_count)
2278        } else if entry_count > 0 {
2279            format!("{} ({} entries)", log.title, entry_count)
2280        } else {
2281            format!("{} (empty)", log.title)
2282        };
2283
2284        let action_log_hints = if log.search_input_active {
2285            ACTION_LOG_SEARCH_INPUT_HINTS
2286        } else {
2287            ACTION_LOG_HINTS
2288        };
2289
2290        self.render_overlay_container(
2291            frame,
2292            app_area,
2293            &title,
2294            Some(action_log_hints),
2295            self.action_log_footer_center_items(log),
2296            |frame, log_area| {
2297                use ratatui::text::{Line, Span};
2298                use ratatui::widgets::Paragraph;
2299
2300                let style = ActionLogStyle::default();
2301                let spacing = 1usize;
2302                let seq_width = 5usize;
2303                let name_width = 20usize;
2304                let elapsed_width = 8usize;
2305
2306                let header_area = Rect {
2307                    height: 1,
2308                    ..log_area
2309                };
2310                let body_area = Rect {
2311                    y: log_area.y.saturating_add(1),
2312                    height: log_area.height.saturating_sub(1),
2313                    ..log_area
2314                };
2315
2316                let selected_visible_idx = if filtered_count == 0 {
2317                    0
2318                } else if filter_active {
2319                    let selected_match_position = log
2320                        .search_matches
2321                        .iter()
2322                        .position(|&idx| idx == log.selected);
2323                    // Defensive fallback in case selection/matches drift out of sync.
2324                    selected_match_position
2325                        .unwrap_or(log.search_match_index.min(filtered_count.saturating_sub(1)))
2326                } else {
2327                    log.selected.min(filtered_count.saturating_sub(1))
2328                };
2329
2330                let show_scrollbar = body_area.height > 0
2331                    && filtered_count > body_area.height as usize
2332                    && log_area.width > 1;
2333                let text_width = if show_scrollbar {
2334                    log_area.width.saturating_sub(1)
2335                } else {
2336                    log_area.width
2337                } as usize;
2338                let params_width = text_width
2339                    .saturating_sub(seq_width + name_width + elapsed_width + spacing * 3)
2340                    .max(1);
2341
2342                let header_line = Line::from(vec![
2343                    Span::styled(pad_text("#", seq_width), style.header),
2344                    Span::styled(" ", style.header),
2345                    Span::styled(pad_text("Action", name_width), style.header),
2346                    Span::styled(" ", style.header),
2347                    Span::styled(pad_text("Params", params_width), style.header),
2348                    Span::styled(" ", style.header),
2349                    Span::styled(pad_text("Elapsed", elapsed_width), style.header),
2350                ]);
2351                frame.render_widget(Paragraph::new(header_line), header_area);
2352
2353                if body_area.height == 0 {
2354                    return;
2355                }
2356
2357                let syntax_style = DebugSyntaxStyle::with_base(style.params);
2358                let rows: Vec<Line> = if filtered_count == 0 {
2359                    vec![Line::from(vec![Span::styled(
2360                        " (no matching actions) ",
2361                        Style::default().fg(DebugStyle::text_secondary()),
2362                    )])]
2363                } else {
2364                    (0..filtered_count)
2365                        .map(|visible_idx| {
2366                            let entry_idx = if filter_active {
2367                                log.search_matches[visible_idx]
2368                            } else {
2369                                visible_idx
2370                            };
2371                            let entry = &log.entries[entry_idx];
2372                            let is_selected = visible_idx == selected_visible_idx;
2373                            let row_style = if is_selected {
2374                                style.selected
2375                            } else if visible_idx % 2 == 0 {
2376                                style.row_styles.0
2377                            } else {
2378                                style.row_styles.1
2379                            };
2380
2381                            let seq_text = pad_text(&entry.sequence.to_string(), seq_width);
2382                            let name_text = pad_text(&entry.name, name_width);
2383                            let elapsed_text = pad_text(&entry.elapsed, elapsed_width);
2384
2385                            let params_compact = entry.params.replace('\n', " ");
2386                            let params_compact = params_compact
2387                                .split_whitespace()
2388                                .collect::<Vec<_>>()
2389                                .join(" ");
2390                            let params_trimmed =
2391                                truncate_with_ellipsis(&params_compact, params_width);
2392                            let params_text = pad_text(&params_trimmed, params_width);
2393                            let mut params_spans: Vec<Span<'static>> =
2394                                debug_spans(&params_text, &syntax_style)
2395                                    .into_iter()
2396                                    .map(|span| span.patch_style(row_style))
2397                                    .collect();
2398
2399                            let mut spans = vec![
2400                                Span::styled(seq_text, style.sequence).patch_style(row_style),
2401                                Span::styled(" ", row_style),
2402                                Span::styled(name_text, style.name).patch_style(row_style),
2403                                Span::styled(" ", row_style),
2404                            ];
2405                            spans.append(&mut params_spans);
2406                            spans.push(Span::styled(" ", row_style));
2407                            spans.push(
2408                                Span::styled(elapsed_text, style.elapsed).patch_style(row_style),
2409                            );
2410
2411                            Line::from(spans)
2412                        })
2413                        .collect()
2414                };
2415
2416                let scroll_style = ScrollViewStyle {
2417                    base: BaseStyle {
2418                        border: None,
2419                        padding: Padding::default(),
2420                        bg: None,
2421                        fg: None,
2422                    },
2423                    scrollbar: self.component_scrollbar_style(),
2424                };
2425                let content_height = rows.len();
2426                let visible_rows = body_area.height as usize;
2427                let scroll_offset = if visible_rows == 0 || selected_visible_idx < visible_rows {
2428                    0
2429                } else {
2430                    selected_visible_idx - visible_rows + 1
2431                };
2432                let scroller = LinesScroller::new(&rows);
2433                let mut scroll_view = ScrollView::new();
2434                <ScrollView as tui_dispatch_core::Component<()>>::render(
2435                    &mut scroll_view,
2436                    frame,
2437                    body_area,
2438                    ScrollViewProps {
2439                        content_height,
2440                        scroll_offset,
2441                        is_focused: true,
2442                        style: scroll_style,
2443                        behavior: ScrollViewBehavior::default(),
2444                        on_scroll: |_| (),
2445                        render_content: &mut scroller.renderer(),
2446                    },
2447                );
2448            },
2449        );
2450    }
2451
2452    fn action_log_footer_center_items(
2453        &self,
2454        log: &ActionLogOverlay,
2455    ) -> Option<Vec<StatusBarItem<'static>>> {
2456        use ratatui::text::Span;
2457
2458        let key_style = Style::default()
2459            .fg(DebugStyle::bg_deep())
2460            .bg(DebugStyle::text_secondary())
2461            .add_modifier(Modifier::BOLD);
2462        let label_style = Style::default().fg(DebugStyle::text_secondary());
2463        let value_style = Style::default().fg(DebugStyle::text_primary());
2464
2465        let filter_value = if log.search_input_active {
2466            format!("/{}_", log.search_query)
2467        } else if log.search_query.is_empty() {
2468            "off".to_string()
2469        } else {
2470            format!("/{}", log.search_query)
2471        };
2472
2473        let matches = if log.search_query.is_empty() {
2474            format!("{}", log.entries.len())
2475        } else {
2476            format!("{}", log.search_match_count())
2477        };
2478
2479        Some(vec![
2480            StatusBarItem::span(Span::styled(" filter ", key_style)),
2481            StatusBarItem::span(Span::styled(format!(" {} ", filter_value), value_style)),
2482            StatusBarItem::span(Span::styled(" matches ", key_style)),
2483            StatusBarItem::span(Span::styled(format!(" {} ", matches), label_style)),
2484        ])
2485    }
2486
2487    fn render_action_detail_modal(
2488        &mut self,
2489        frame: &mut Frame,
2490        app_area: Rect,
2491        detail: &super::table::ActionDetailOverlay,
2492    ) {
2493        let title = format!("Action #{} - {}", detail.sequence, detail.name);
2494        let param_lines = self.detail_params_lines(detail);
2495        let scrollbar_style = self.component_scrollbar_style();
2496        let detail_scroll_offset = self.detail_scroll_offset;
2497        let detail_page_size = self.detail_page_size;
2498
2499        self.render_overlay_container(
2500            frame,
2501            app_area,
2502            &title,
2503            Some(ACTION_DETAIL_HINTS),
2504            None,
2505            |frame, detail_area| {
2506                use ratatui::text::{Line, Span};
2507                use ratatui::widgets::Paragraph;
2508
2509                let label_style = Style::default().fg(DebugStyle::text_secondary());
2510                let value_style = Style::default().fg(DebugStyle::text_primary());
2511
2512                let header_lines = vec![
2513                    Line::from(vec![
2514                        Span::styled("Name: ", label_style),
2515                        Span::styled(&detail.name, value_style),
2516                    ]),
2517                    Line::from(vec![
2518                        Span::styled("Sequence: ", label_style),
2519                        Span::styled(detail.sequence.to_string(), value_style),
2520                    ]),
2521                    Line::from(vec![
2522                        Span::styled("Elapsed: ", label_style),
2523                        Span::styled(&detail.elapsed, value_style),
2524                    ]),
2525                    Line::from(""),
2526                    Line::from(Span::styled("Parameters:", label_style)),
2527                ];
2528
2529                let mut param_lines = param_lines.clone();
2530                if param_lines.is_empty() {
2531                    param_lines.push(Line::from(Span::styled("  (none)", value_style)));
2532                }
2533                let param_lines_len = param_lines.len();
2534
2535                let footer_lines = vec![Line::from("")];
2536
2537                let header_height = header_lines.len() as u16;
2538                let footer_height = footer_lines.len() as u16;
2539
2540                let header_area_height = header_height.min(detail_area.height);
2541                let header_area = Rect {
2542                    height: header_area_height,
2543                    ..detail_area
2544                };
2545                if header_area.height > 0 {
2546                    let paragraph = Paragraph::new(header_lines);
2547                    frame.render_widget(paragraph, header_area);
2548                }
2549
2550                let footer_area_height =
2551                    footer_height.min(detail_area.height.saturating_sub(header_area_height));
2552                let footer_area = Rect {
2553                    x: detail_area.x,
2554                    y: detail_area
2555                        .y
2556                        .saturating_add(detail_area.height.saturating_sub(footer_area_height)),
2557                    width: detail_area.width,
2558                    height: footer_area_height,
2559                };
2560                if footer_area.height > 0 {
2561                    let paragraph = Paragraph::new(footer_lines);
2562                    frame.render_widget(paragraph, footer_area);
2563                }
2564
2565                let params_area = Rect {
2566                    x: detail_area.x,
2567                    y: detail_area.y.saturating_add(header_area_height),
2568                    width: detail_area.width,
2569                    height: detail_area
2570                        .height
2571                        .saturating_sub(header_area_height + footer_area_height),
2572                };
2573
2574                // Note: page size and scroll offset updates removed to avoid borrow issues
2575                let max_offset = param_lines_len.saturating_sub(detail_page_size.max(1));
2576                let current_scroll_offset = detail_scroll_offset.min(max_offset);
2577
2578                if params_area.height > 0 {
2579                    let scroll_style = ScrollViewStyle {
2580                        base: BaseStyle {
2581                            border: None,
2582                            padding: Padding::default(),
2583                            bg: Some(DebugStyle::overlay_bg_dark()),
2584                            fg: None,
2585                        },
2586                        scrollbar: scrollbar_style.clone(),
2587                    };
2588
2589                    let scroller = LinesScroller::new(&param_lines);
2590                    let mut scroll_view = ScrollView::new();
2591                    <ScrollView as tui_dispatch_core::Component<()>>::render(
2592                        &mut scroll_view,
2593                        frame,
2594                        params_area,
2595                        ScrollViewProps {
2596                            content_height: param_lines_len,
2597                            scroll_offset: current_scroll_offset,
2598                            is_focused: true,
2599                            style: scroll_style,
2600                            behavior: ScrollViewBehavior::default(),
2601                            on_scroll: |_| (),
2602                            render_content: &mut scroller.renderer(),
2603                        },
2604                    );
2605                }
2606            },
2607        );
2608    }
2609
2610    fn detail_params_lines(
2611        &self,
2612        detail: &super::table::ActionDetailOverlay,
2613    ) -> Vec<ratatui::text::Line<'static>> {
2614        use ratatui::text::{Line, Span};
2615
2616        if detail.params.is_empty() {
2617            return Vec::new();
2618        }
2619
2620        let value_style = Style::default().fg(DebugStyle::text_primary());
2621        let syntax_style = DebugSyntaxStyle::with_base(value_style);
2622
2623        detail
2624            .params
2625            .lines()
2626            .map(|line| {
2627                let mut spans = vec![Span::styled("  ", value_style)];
2628                spans.extend(debug_spans(line, &syntax_style));
2629                Line::from(spans)
2630            })
2631            .collect()
2632    }
2633
2634    fn build_inspect_overlay(&self, column: u16, row: u16, snapshot: &Buffer) -> DebugTableOverlay {
2635        let mut builder = DebugTableBuilder::new();
2636
2637        builder.push_section("Position");
2638        builder.push_entry("column", column.to_string());
2639        builder.push_entry("row", row.to_string());
2640
2641        if let Some(preview) = inspect_cell(snapshot, column, row) {
2642            builder.set_cell_preview(preview);
2643        }
2644
2645        builder.finish(format!("Inspect ({column}, {row})"))
2646    }
2647}
2648
2649#[derive(Debug, Serialize)]
2650struct DebugStateSnapshot {
2651    title: String,
2652    sections: Vec<DebugStateSnapshotSection>,
2653}
2654
2655#[derive(Debug, Serialize)]
2656struct DebugStateSnapshotSection {
2657    title: String,
2658    entries: Vec<DebugStateSnapshotEntry>,
2659}
2660
2661#[derive(Debug, Serialize)]
2662struct DebugStateSnapshotEntry {
2663    key: String,
2664    value: String,
2665}
2666
2667impl DebugStateSnapshot {
2668    fn from_state<S: DebugState>(state: &S, title: &str) -> Self {
2669        let sections = state
2670            .debug_sections()
2671            .into_iter()
2672            .map(|section| DebugStateSnapshotSection {
2673                title: section.title,
2674                entries: section
2675                    .entries
2676                    .into_iter()
2677                    .map(|entry| DebugStateSnapshotEntry {
2678                        key: entry.key,
2679                        value: entry.value,
2680                    })
2681                    .collect(),
2682            })
2683            .collect();
2684
2685        Self {
2686            title: title.to_string(),
2687            sections,
2688        }
2689    }
2690}
2691
2692fn pad_text(value: &str, width: usize) -> String {
2693    if width == 0 {
2694        return String::new();
2695    }
2696    let mut text: String = value.chars().take(width).collect();
2697    let len = text.chars().count();
2698    if len < width {
2699        text.push_str(&" ".repeat(width - len));
2700    }
2701    text
2702}
2703
2704fn truncate_with_ellipsis(value: &str, width: usize) -> String {
2705    if width == 0 {
2706        return String::new();
2707    }
2708    let count = value.chars().count();
2709    if count <= width {
2710        return value.to_string();
2711    }
2712    if width <= 3 {
2713        return value.chars().take(width).collect();
2714    }
2715    let mut text: String = value.chars().take(width - 3).collect();
2716    text.push_str("...");
2717    text
2718}
2719
2720/// Format a KeyCode for display in the banner.
2721fn format_key(key: KeyCode) -> String {
2722    match key {
2723        KeyCode::F(n) => format!("F{}", n),
2724        KeyCode::Char(c) => c.to_string(),
2725        KeyCode::Esc => "Esc".to_string(),
2726        KeyCode::Enter => "Enter".to_string(),
2727        KeyCode::Tab => "Tab".to_string(),
2728        KeyCode::Backspace => "Bksp".to_string(),
2729        KeyCode::Delete => "Del".to_string(),
2730        KeyCode::Up => "↑".to_string(),
2731        KeyCode::Down => "↓".to_string(),
2732        KeyCode::Left => "←".to_string(),
2733        KeyCode::Right => "→".to_string(),
2734        _ => format!("{:?}", key),
2735    }
2736}
2737
2738#[cfg(test)]
2739mod tests {
2740    use super::*;
2741
2742    #[derive(Debug, Clone)]
2743    enum TestAction {
2744        Foo,
2745        Bar,
2746    }
2747
2748    impl tui_dispatch_core::Action for TestAction {
2749        fn name(&self) -> &'static str {
2750            match self {
2751                TestAction::Foo => "Foo",
2752                TestAction::Bar => "Bar",
2753            }
2754        }
2755    }
2756
2757    impl tui_dispatch_core::ActionParams for TestAction {
2758        fn params(&self) -> String {
2759            String::new()
2760        }
2761    }
2762
2763    #[test]
2764    fn test_debug_layer_creation() {
2765        let layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2766        assert!(!layer.is_enabled());
2767        assert!(layer.freeze().snapshot.is_none());
2768    }
2769
2770    #[test]
2771    fn test_toggle() {
2772        let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2773
2774        // Enable
2775        let effect = layer.toggle();
2776        assert!(effect.is_none());
2777        assert!(layer.is_enabled());
2778
2779        // Disable
2780        let effect = layer.toggle();
2781        assert!(effect.is_none()); // No queued actions
2782        assert!(!layer.is_enabled());
2783    }
2784
2785    #[test]
2786    fn test_set_enabled() {
2787        let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2788
2789        layer.set_enabled(true);
2790        assert!(layer.is_enabled());
2791
2792        layer.set_enabled(false);
2793        assert!(!layer.is_enabled());
2794    }
2795
2796    #[test]
2797    fn test_simple_constructor() {
2798        let layer: DebugLayer<TestAction> = DebugLayer::simple();
2799        assert!(!layer.is_enabled());
2800    }
2801
2802    #[test]
2803    fn test_queued_actions_returned_on_disable() {
2804        let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2805
2806        layer.toggle(); // Enable
2807        layer.queue_action(TestAction::Foo);
2808        layer.queue_action(TestAction::Bar);
2809
2810        let effect = layer.toggle(); // Disable
2811
2812        match effect {
2813            Some(DebugSideEffect::ProcessQueuedActions(actions)) => {
2814                assert_eq!(actions.len(), 2);
2815            }
2816            _ => panic!("Expected ProcessQueuedActions"),
2817        }
2818    }
2819
2820    #[test]
2821    fn test_split_area() {
2822        let layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2823
2824        // Disabled: full area returned
2825        let area = Rect::new(0, 0, 80, 24);
2826        let (app, banner) = layer.split_area(area);
2827        assert_eq!(app, area);
2828        assert_eq!(banner, Rect::ZERO);
2829    }
2830
2831    #[test]
2832    fn test_split_area_enabled() {
2833        let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2834        layer.toggle();
2835
2836        let area = Rect::new(0, 0, 80, 24);
2837        let (app, banner) = layer.split_area(area);
2838
2839        assert_eq!(app.height, 23);
2840        assert_eq!(banner.height, 1);
2841        assert_eq!(banner.y, 23);
2842    }
2843
2844    #[test]
2845    fn test_split_area_enabled_top() {
2846        let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2847        layer.toggle();
2848        layer.set_banner_position(BannerPosition::Top);
2849
2850        let area = Rect::new(0, 0, 80, 24);
2851        let (app, banner) = layer.split_area(area);
2852
2853        assert_eq!(banner.y, 0);
2854        assert_eq!(banner.height, 1);
2855        assert_eq!(app.y, 1);
2856        assert_eq!(app.height, 23);
2857    }
2858
2859    #[test]
2860    fn test_inactive_layer() {
2861        let layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12)).active(false);
2862
2863        assert!(!layer.is_active());
2864        assert!(!layer.is_enabled());
2865    }
2866
2867    #[test]
2868    fn test_action_log() {
2869        let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2870
2871        layer.log_action(&TestAction::Foo);
2872        layer.log_action(&TestAction::Bar);
2873
2874        assert_eq!(layer.action_log().entries().count(), 2);
2875    }
2876
2877    #[test]
2878    fn test_action_log_filter_configuration() {
2879        let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12))
2880            .with_action_log_filter(ActionLoggerConfig::new(Some("name:Foo"), None));
2881
2882        layer.log_action(&TestAction::Foo);
2883        layer.log_action(&TestAction::Bar);
2884
2885        let names: Vec<_> = layer
2886            .action_log()
2887            .entries()
2888            .map(|entry| entry.name)
2889            .collect();
2890        assert_eq!(names, vec!["Foo"]);
2891    }
2892
2893    #[test]
2894    fn test_action_log_search_input_does_not_trigger_global_shortcuts() {
2895        use crossterm::event::{KeyEventKind, KeyEventState};
2896        use tui_dispatch_core::EventKind;
2897
2898        let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2899        layer.toggle();
2900        layer.show_action_log();
2901
2902        if let Some(DebugOverlay::ActionLog(log)) = layer.freeze.overlay.as_mut() {
2903            log.search_input_active = true;
2904        } else {
2905            panic!("expected action log overlay");
2906        }
2907
2908        let keys = [
2909            KeyEvent {
2910                code: KeyCode::Char('A'),
2911                modifiers: KeyModifiers::SHIFT,
2912                kind: KeyEventKind::Press,
2913                state: KeyEventState::NONE,
2914            },
2915            KeyEvent {
2916                code: KeyCode::Char('s'),
2917                modifiers: KeyModifiers::NONE,
2918                kind: KeyEventKind::Press,
2919                state: KeyEventState::NONE,
2920            },
2921            KeyEvent {
2922                code: KeyCode::Char('b'),
2923                modifiers: KeyModifiers::NONE,
2924                kind: KeyEventKind::Press,
2925                state: KeyEventState::NONE,
2926            },
2927            KeyEvent {
2928                code: KeyCode::Char('i'),
2929                modifiers: KeyModifiers::NONE,
2930                kind: KeyEventKind::Press,
2931                state: KeyEventState::NONE,
2932            },
2933        ];
2934        for key in keys {
2935            let outcome = layer.handle_event(&EventKind::Key(key));
2936            assert!(outcome.consumed);
2937        }
2938
2939        match layer.freeze.overlay.as_ref() {
2940            Some(DebugOverlay::ActionLog(log)) => {
2941                assert!(log.search_input_active);
2942                assert_eq!(log.search_query, "Asbi");
2943            }
2944            _ => panic!("action log overlay should remain open"),
2945        }
2946    }
2947}