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::rc::Rc;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use base64::prelude::*;
14use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind};
15use ratatui::buffer::Buffer;
16use ratatui::layout::Rect;
17use ratatui::style::{Modifier, Style, Stylize};
18use ratatui::text::{Line, Span};
19use ratatui::Frame;
20use serde::Serialize;
21
22use super::action_logger::{ActionLog, ActionLogConfig, ActionLoggerConfig};
23use super::actions::{DebugAction, DebugSideEffect};
24use super::cell::inspect_cell;
25use super::config::DebugStyle;
26use super::state::DebugState;
27use super::table::{
28    ActionLogOverlay, DebugOverlay, DebugTableBuilder, DebugTableOverlay, DebugTableRow,
29};
30use super::widgets::{
31    debug_spans, dim_buffer, paint_snapshot, ActionLogStyle, CellPreviewWidget, DebugSyntaxStyle,
32    DebugTableStyle,
33};
34use super::DebugFreeze;
35
36use tui_dispatch_components::{
37    centered_rect, BaseStyle, LinesScroller, Modal, ModalBehavior, ModalProps, ModalStyle, Padding,
38    ScrollView, ScrollViewBehavior, ScrollViewProps, ScrollViewStyle,
39    ScrollbarStyle as ComponentScrollbarStyle, SelectionStyle, StatusBar, StatusBarItem,
40    StatusBarProps, StatusBarSection, StatusBarStyle, TreeBranchMode, TreeBranchStyle, TreeNode,
41    TreeNodeRender, TreeView, TreeViewBehavior, TreeViewProps, TreeViewStyle,
42};
43
44#[cfg(feature = "subscriptions")]
45use tui_dispatch_core::subscriptions::SubPauseHandle;
46#[cfg(feature = "tasks")]
47use tui_dispatch_core::tasks::TaskPauseHandle;
48use tui_dispatch_core::{Action, Component, EventKind};
49
50type StateSnapshotter = Box<dyn Fn(&dyn Any, &Path) -> crate::SnapshotResult<()> + 'static>;
51
52#[derive(Debug, Clone)]
53enum StateTreeAction {
54    Select(String),
55    Toggle(String, bool),
56}
57
58fn state_tree_select(id: &str) -> StateTreeAction {
59    StateTreeAction::Select(id.to_owned())
60}
61
62fn state_tree_toggle(id: &str, expanded: bool) -> StateTreeAction {
63    StateTreeAction::Toggle(id.to_owned(), expanded)
64}
65
66struct InlineValueStyle {
67    base: Style,
68    key: Style,
69    string: Style,
70    number: Style,
71    r#type: Style,
72}
73
74fn ron_value_spans(value: &str, style: &InlineValueStyle) -> Vec<Span<'static>> {
75    let chars: Vec<char> = value.chars().collect();
76    let mut spans = Vec::new();
77    let mut idx = 0;
78
79    while idx < chars.len() {
80        let current = chars[idx];
81        if current == '"' {
82            let start = idx;
83            idx += 1;
84            while idx < chars.len() {
85                if chars[idx] == '"' && chars.get(idx.saturating_sub(1)) != Some(&'\\') {
86                    idx += 1;
87                    break;
88                }
89                idx += 1;
90            }
91            let text: String = chars[start..idx].iter().collect();
92            spans.push(Span::styled(text, style.string));
93            continue;
94        }
95
96        if current.is_ascii_digit()
97            || (current == '-' && chars.get(idx + 1).is_some_and(|c| c.is_ascii_digit()))
98        {
99            let start = idx;
100            idx += 1;
101            while idx < chars.len() {
102                let c = chars[idx];
103                if c.is_ascii_digit() || matches!(c, '.' | '_' | 'e' | 'E' | '+' | '-') {
104                    idx += 1;
105                } else {
106                    break;
107                }
108            }
109            let text: String = chars[start..idx].iter().collect();
110            spans.push(Span::styled(text, style.number));
111            continue;
112        }
113
114        if current.is_alphanumeric() || current == '_' {
115            let start = idx;
116            idx += 1;
117            while idx < chars.len() {
118                let c = chars[idx];
119                if c.is_alphanumeric() || c == '_' {
120                    idx += 1;
121                } else {
122                    break;
123                }
124            }
125            let mut text: String = chars[start..idx].iter().collect();
126            let mut span_style = style.base;
127
128            if idx < chars.len() && chars[idx] == ':' {
129                text.push(':');
130                idx += 1;
131                span_style = style.key;
132            } else if text == "true" || text == "false" {
133                span_style = style.number;
134            } else if text.chars().next().is_some_and(|c| c.is_uppercase()) {
135                span_style = style.r#type;
136            }
137
138            spans.push(Span::styled(text, span_style));
139            continue;
140        }
141
142        spans.push(Span::styled(current.to_string(), style.base));
143        idx += 1;
144    }
145
146    spans
147}
148
149fn render_state_tree_node(
150    ctx: TreeNodeRender<'_, String, String>,
151    palette: &DebugStyle,
152) -> Line<'static> {
153    // Section header style - bold accent color for expandable sections
154    let section_style = Style::default()
155        .fg(palette.accent)
156        .add_modifier(Modifier::BOLD);
157    // Key/field label style
158    let key_style = Style::default().fg(palette.text_primary).bold();
159    // Value styling
160    let value_style = Style::default().fg(palette.text_primary);
161    let type_style = Style::default().fg(palette.neon_purple);
162    let string_style = Style::default().fg(palette.neon_green);
163    let number_style = Style::default().fg(palette.neon_amber);
164
165    let selection_patch = if ctx.is_selected {
166        Style::default().bg(palette.bg_highlight)
167    } else {
168        Style::default()
169    };
170
171    let content_width = ctx.available_width.max(1);
172    let mut spans = Vec::new();
173
174    // Section headers (nodes with children) - bold accent style
175    if ctx.has_children {
176        let text = truncate_with_ellipsis(&ctx.node.value, content_width);
177        spans.push(Span::styled(text, section_style).patch_style(selection_patch));
178        return Line::from(spans);
179    }
180
181    // Key: value pairs - render as "key: value" with syntax highlighting
182    if let Some((key, value)) = ctx.node.value.split_once(": ") {
183        let key_text = format!("{key}: ");
184        let key_len = key_text.chars().count();
185        spans.push(Span::styled(key_text, key_style).patch_style(selection_patch));
186
187        let remaining = content_width.saturating_sub(key_len);
188        if remaining > 0 {
189            let value_text = truncate_with_ellipsis(value, remaining);
190            let inline_style = InlineValueStyle {
191                base: value_style,
192                key: key_style,
193                string: string_style,
194                number: number_style,
195                r#type: type_style,
196            };
197            let value_spans = ron_value_spans(&value_text, &inline_style);
198            for span in value_spans {
199                spans.push(span.patch_style(selection_patch));
200            }
201        }
202
203        return Line::from(spans);
204    }
205
206    // Plain text nodes
207    let text = truncate_with_ellipsis(&ctx.node.value, content_width);
208    spans.push(Span::styled(text, value_style).patch_style(selection_patch));
209
210    Line::from(spans)
211}
212
213/// Location of the debug banner relative to the app area.
214#[derive(Clone, Copy, Debug, PartialEq, Eq)]
215pub enum BannerPosition {
216    Bottom,
217    Top,
218}
219
220impl BannerPosition {
221    /// Toggle between top and bottom.
222    pub fn toggle(self) -> Self {
223        match self {
224            Self::Bottom => Self::Top,
225            Self::Top => Self::Bottom,
226        }
227    }
228
229    fn label(self) -> &'static str {
230        match self {
231            Self::Bottom => "bar:bottom",
232            Self::Top => "bar:top",
233        }
234    }
235}
236
237// ============================================================================
238// Modal Footer Hints
239// ============================================================================
240
241/// A single hint for modal footer status bar
242#[derive(Debug, Clone, Copy)]
243pub struct ModalHint {
244    pub key: &'static str,
245    pub label: &'static str,
246}
247
248impl ModalHint {
249    pub const fn new(key: &'static str, label: &'static str) -> Self {
250        Self { key, label }
251    }
252}
253
254/// Hints configuration for a modal footer
255#[derive(Debug, Clone, Copy)]
256pub struct ModalHints {
257    pub left: &'static [ModalHint],
258    pub right: &'static [ModalHint],
259}
260
261// Action Log Modal hints
262const ACTION_LOG_HINTS: ModalHints = ModalHints {
263    left: &[
264        ModalHint::new("j/k", "scroll"),
265        ModalHint::new("g/G", "top/bottom"),
266        ModalHint::new("/", "filter"),
267    ],
268    right: &[
269        ModalHint::new("n/N", "next/prev"),
270        ModalHint::new("Enter", "details"),
271        ModalHint::new("Bksp", "close"),
272    ],
273};
274
275// Action Log Modal hints while search input is active
276const ACTION_LOG_SEARCH_INPUT_HINTS: ModalHints = ModalHints {
277    left: &[
278        ModalHint::new("type", "query"),
279        ModalHint::new("Bksp", "edit"),
280    ],
281    right: &[
282        ModalHint::new("Enter", "done"),
283        ModalHint::new("Esc", "done"),
284    ],
285};
286
287// State Tree Modal hints
288const STATE_TREE_HINTS: ModalHints = ModalHints {
289    left: &[
290        ModalHint::new("j/k", "scroll"),
291        ModalHint::new("Space", "expand"),
292        ModalHint::new("Enter", "detail"),
293        ModalHint::new("/", "filter"),
294    ],
295    right: &[ModalHint::new("w", "save"), ModalHint::new("Bksp", "close")],
296};
297
298// Action Detail Modal hints
299const ACTION_DETAIL_HINTS: ModalHints = ModalHints {
300    left: &[ModalHint::new("j/k", "scroll")],
301    right: &[ModalHint::new("Bksp", "back")],
302};
303
304// State Entry Detail hints
305const STATE_DETAIL_HINTS: ModalHints = ModalHints {
306    left: &[ModalHint::new("j/k", "scroll")],
307    right: &[ModalHint::new("Bksp", "back")],
308};
309
310// Components Modal hints
311const COMPONENTS_HINTS: ModalHints = ModalHints {
312    left: &[
313        ModalHint::new("j/k", "scroll"),
314        ModalHint::new("Space", "expand"),
315        ModalHint::new("Enter", "detail"),
316        ModalHint::new("/", "filter"),
317    ],
318    right: &[ModalHint::new("Bksp", "close")],
319};
320
321// Search input mode hints (shared by state tree + components)
322const SEARCH_INPUT_HINTS: ModalHints = ModalHints {
323    left: &[
324        ModalHint::new("type", "query"),
325        ModalHint::new("Bksp", "edit"),
326    ],
327    right: &[
328        ModalHint::new("Enter", "done"),
329        ModalHint::new("Esc", "done"),
330    ],
331};
332
333// Component Detail hints
334const COMPONENT_DETAIL_HINTS: ModalHints = ModalHints {
335    left: &[ModalHint::new("j/k", "scroll")],
336    right: &[ModalHint::new("Bksp", "back")],
337};
338
339// Inspect Modal hints
340const INSPECT_HINTS: ModalHints = ModalHints {
341    left: &[ModalHint::new("j/k", "scroll")],
342    right: &[ModalHint::new("Bksp", "close")],
343};
344
345/// Result of handling a debug event.
346pub struct DebugOutcome<A> {
347    /// Whether the debug layer consumed the event.
348    pub consumed: bool,
349    /// Actions queued while debug was active (e.g., from pause/resume).
350    pub queued_actions: Vec<A>,
351    /// Whether a re-render is needed.
352    pub needs_render: bool,
353}
354
355impl<A> DebugOutcome<A> {
356    fn ignored() -> Self {
357        Self {
358            consumed: false,
359            queued_actions: Vec::new(),
360            needs_render: false,
361        }
362    }
363
364    fn consumed(queued_actions: Vec<A>) -> Self {
365        Self {
366            consumed: true,
367            queued_actions,
368            needs_render: true,
369        }
370    }
371
372    /// Dispatch queued actions if the debug layer consumed the event.
373    ///
374    /// Returns `Some(needs_render)` when consumed, otherwise `None`.
375    pub fn dispatch_queued<F>(self, mut dispatch: F) -> Option<bool>
376    where
377        F: FnMut(A),
378    {
379        if !self.consumed {
380            return None;
381        }
382
383        for action in self.queued_actions {
384            dispatch(action);
385        }
386
387        Some(self.needs_render)
388    }
389}
390
391impl<A> Default for DebugOutcome<A> {
392    fn default() -> Self {
393        Self::ignored()
394    }
395}
396
397/// High-level debug layer with minimal configuration.
398///
399/// Provides automatic freeze/unfreeze with pause/resume of tasks and subscriptions.
400///
401/// # Example
402///
403/// ```ignore
404/// use tui_dispatch::debug::DebugLayer;
405///
406/// // Minimal setup with sensible defaults (F12 toggle key)
407/// let mut debug = DebugLayer::simple()
408///     .with_task_manager(&tasks)
409///     .with_subscriptions(&subs)
410///     .active(args.debug);
411///
412/// // In event loop
413/// if debug.intercepts(&event) {
414///     continue;
415/// }
416///
417/// // In render
418/// debug.render(frame, |f, area| {
419///     app.render(f, area);
420/// });
421///
422/// // Log actions for the action log feature
423/// debug.log_action(&action);
424/// ```
425pub struct DebugLayer<A> {
426    /// Key to toggle debug mode
427    toggle_key: KeyCode,
428    /// Internal freeze state
429    freeze: DebugFreeze<A>,
430    /// Where the debug banner is rendered
431    banner_position: BannerPosition,
432    /// Style configuration
433    style: DebugStyle,
434    /// Whether the debug layer is active (can be disabled for release builds)
435    active: bool,
436    /// Action log for display
437    action_log: ActionLog,
438    /// Cached state snapshot for the state overlay
439    state_snapshot: Option<DebugTableOverlay>,
440    /// Component snapshotter callback (returns component debug info)
441    component_snapshotter: Option<Box<dyn Fn() -> Vec<super::table::ComponentSnapshot>>>,
442    /// Optional serializer for saving state snapshots
443    state_snapshotter: Option<StateSnapshotter>,
444    /// Tree view component for state overlay
445    state_tree_view: TreeView<String>,
446    /// Cached tree nodes for state overlay
447    state_tree_nodes: Vec<TreeNode<String, String>>,
448    /// Currently selected tree node id
449    state_tree_selected: Option<String>,
450    /// Expanded node ids for the state tree
451    state_tree_expanded: HashSet<String>,
452    /// Search query for the state tree
453    state_search_query: String,
454    /// Whether state search input is active
455    state_search_active: bool,
456    /// Filtered state tree nodes (recomputed on query/node change)
457    state_tree_filtered: Vec<TreeNode<String, String>>,
458    /// Tree view component for components overlay
459    components_tree_view: TreeView<String>,
460    /// Cached tree nodes for components overlay
461    components_tree_nodes: Vec<TreeNode<String, String>>,
462    /// Currently selected component tree node id
463    components_tree_selected: Option<String>,
464    /// Expanded node ids for the components tree
465    components_tree_expanded: HashSet<String>,
466    /// Search query for the components tree
467    components_search_query: String,
468    /// Whether components search input is active
469    components_search_active: bool,
470    /// Filtered components tree nodes (recomputed on query/node change)
471    components_tree_filtered: Vec<TreeNode<String, String>>,
472    /// Scroll state for state/inspect table overlays
473    table_scroll: super::scroll_state::ScrollState,
474    /// Scroll state for action detail modal
475    detail_scroll: super::scroll_state::ScrollState,
476    /// Handle to pause/resume task manager
477    #[cfg(feature = "tasks")]
478    task_handle: Option<TaskPauseHandle<A>>,
479    /// Handle to pause/resume subscriptions
480    #[cfg(feature = "subscriptions")]
481    sub_handle: Option<SubPauseHandle>,
482}
483
484impl<A> std::fmt::Debug for DebugLayer<A> {
485    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
486        f.debug_struct("DebugLayer")
487            .field("toggle_key", &self.toggle_key)
488            .field("active", &self.active)
489            .field("enabled", &self.freeze.enabled)
490            .field("has_snapshot", &self.freeze.snapshot.is_some())
491            .field("has_state_snapshot", &self.state_snapshot.is_some())
492            .field("has_state_snapshotter", &self.state_snapshotter.is_some())
493            .field(
494                "has_component_snapshotter",
495                &self.component_snapshotter.is_some(),
496            )
497            .field("state_tree_nodes", &self.state_tree_nodes.len())
498            .field("state_tree_selected", &self.state_tree_selected)
499            .field("banner_position", &self.banner_position)
500            .field("table_scroll", &self.table_scroll)
501            .field("detail_scroll", &self.detail_scroll)
502            .field("queued_actions", &self.freeze.queued_actions.len())
503            .finish()
504    }
505}
506
507impl<A: Action> DebugLayer<A> {
508    /// Create a new debug layer with the given toggle key.
509    ///
510    /// # Example
511    ///
512    /// ```ignore
513    /// use crossterm::event::KeyCode;
514    ///
515    /// let debug = DebugLayer::new(KeyCode::F(12));
516    /// ```
517    pub fn new(toggle_key: KeyCode) -> Self {
518        Self {
519            toggle_key,
520            freeze: DebugFreeze::new(),
521            banner_position: BannerPosition::Bottom,
522            style: DebugStyle::default(),
523            active: true,
524            action_log: ActionLog::new(ActionLogConfig::with_capacity(100)),
525            state_snapshot: None,
526            component_snapshotter: None,
527            state_snapshotter: None,
528            state_tree_view: TreeView::new(),
529            state_tree_nodes: Vec::new(),
530            state_tree_selected: None,
531            state_tree_expanded: HashSet::new(),
532            state_search_query: String::new(),
533            state_search_active: false,
534            state_tree_filtered: Vec::new(),
535            components_tree_view: TreeView::new(),
536            components_tree_nodes: Vec::new(),
537            components_tree_selected: None,
538            components_tree_expanded: HashSet::new(),
539            components_search_query: String::new(),
540            components_search_active: false,
541            components_tree_filtered: Vec::new(),
542            table_scroll: super::scroll_state::ScrollState::new(),
543            detail_scroll: super::scroll_state::ScrollState::new(),
544            #[cfg(feature = "tasks")]
545            task_handle: None,
546            #[cfg(feature = "subscriptions")]
547            sub_handle: None,
548        }
549    }
550
551    /// Create a debug layer with sensible defaults (F12 toggle key).
552    pub fn simple() -> Self {
553        Self::new(KeyCode::F(12))
554    }
555
556    /// Create a debug layer with a custom toggle key.
557    pub fn simple_with_toggle_key(toggle_key: KeyCode) -> Self {
558        Self::new(toggle_key)
559    }
560
561    /// Set whether the debug layer is active.
562    ///
563    /// When inactive (`false`), all methods become no-ops with zero overhead.
564    pub fn active(mut self, active: bool) -> Self {
565        self.active = active;
566        self
567    }
568
569    /// Set the initial banner position.
570    pub fn with_banner_position(mut self, position: BannerPosition) -> Self {
571        self.banner_position = position;
572        self
573    }
574
575    /// Connect a task manager for automatic pause/resume.
576    ///
577    /// When debug mode is enabled, the task manager will be paused.
578    /// When disabled, queued actions will be returned.
579    #[cfg(feature = "tasks")]
580    pub fn with_task_manager(mut self, tasks: &tui_dispatch_core::tasks::TaskManager<A>) -> Self {
581        self.task_handle = Some(tasks.pause_handle());
582        self
583    }
584
585    /// Connect subscriptions for automatic pause/resume.
586    ///
587    /// When debug mode is enabled, subscriptions will be paused.
588    #[cfg(feature = "subscriptions")]
589    pub fn with_subscriptions(
590        mut self,
591        subs: &tui_dispatch_core::subscriptions::Subscriptions<A>,
592    ) -> Self {
593        self.sub_handle = Some(subs.pause_handle());
594        self
595    }
596
597    /// Set the action log capacity.
598    pub fn with_action_log_capacity(mut self, capacity: usize) -> Self {
599        self.action_log = ActionLog::new(ActionLogConfig::with_capacity(capacity));
600        self
601    }
602
603    /// Set full action-log configuration (capacity + filter).
604    pub fn with_action_log_config(mut self, config: ActionLogConfig) -> Self {
605        self.action_log = ActionLog::new(config);
606        self
607    }
608
609    /// Set action-log filtering while keeping the current capacity.
610    pub fn with_action_log_filter(mut self, filter: ActionLoggerConfig) -> Self {
611        let capacity = self.action_log.config().capacity;
612        self.action_log = ActionLog::new(ActionLogConfig::new(capacity, filter));
613        self
614    }
615
616    /// Set custom style.
617    pub fn with_style(mut self, style: DebugStyle) -> Self {
618        self.style = style;
619        self
620    }
621
622    /// Provide a custom serializer for saving state snapshots (W key).
623    pub fn with_state_snapshotter<S, F>(mut self, snapshotter: F) -> Self
624    where
625        S: DebugState + 'static,
626        F: Fn(&S, &Path) -> crate::SnapshotResult<()> + 'static,
627    {
628        self.state_snapshotter = Some(Box::new(move |state, path| {
629            let state = state.downcast_ref::<S>().ok_or_else(|| {
630                crate::SnapshotError::Io(io::Error::new(
631                    io::ErrorKind::InvalidInput,
632                    "debug state snapshot type mismatch",
633                ))
634            })?;
635            snapshotter(state, path)
636        }));
637        self
638    }
639
640    /// Save full state snapshots using serde (loadable by `--debug-state-in`).
641    pub fn with_state_snapshots<S>(self) -> Self
642    where
643        S: DebugState + Serialize + 'static,
644    {
645        self.with_state_snapshotter::<S, _>(|state, path| crate::save_json(path, state))
646    }
647
648    /// Connect a component host for the components debug tab.
649    ///
650    /// The provided closure is called when opening the components overlay
651    /// to snapshot the current mounted component state.
652    pub fn with_component_snapshotter<F>(mut self, snapshotter: F) -> Self
653    where
654        F: Fn() -> Vec<super::table::ComponentSnapshot> + 'static,
655    {
656        self.component_snapshotter = Some(Box::new(snapshotter));
657        self
658    }
659
660    /// Connect a `ComponentHost` directly for the components debug tab.
661    ///
662    /// This is a convenience wrapper around [`with_component_snapshotter`](Self::with_component_snapshotter).
663    pub fn with_component_host<S, A2, Id, Ctx>(
664        self,
665        host: &tui_dispatch_components::ComponentHost<S, A2, Id, Ctx>,
666    ) -> Self
667    where
668        S: 'static,
669        A2: 'static,
670        Id: tui_dispatch_core::ComponentId + 'static,
671        Ctx: tui_dispatch_core::BindingContext + 'static,
672    {
673        let host = host.clone();
674        self.with_component_snapshotter(move || {
675            host.mounted_components()
676                .iter()
677                .map(super::table::ComponentSnapshot::from_mounted_info)
678                .collect()
679        })
680    }
681
682    /// Check if the debug layer is active.
683    pub fn is_active(&self) -> bool {
684        self.active
685    }
686
687    /// Check if debug mode is enabled (and layer is active).
688    pub fn is_enabled(&self) -> bool {
689        self.active && self.freeze.enabled
690    }
691
692    /// Toggle debug mode on/off and return any side effects.
693    ///
694    /// Returns `None` when the layer is inactive or no side effects are needed.
695    pub fn toggle_enabled(&mut self) -> Option<DebugSideEffect<A>> {
696        if !self.active {
697            return None;
698        }
699        self.toggle()
700    }
701
702    /// Set debug mode on/off and return any side effects.
703    ///
704    /// Returns `None` when the layer is inactive or already in the requested state.
705    pub fn set_enabled(&mut self, enabled: bool) -> Option<DebugSideEffect<A>> {
706        if !self.active || enabled == self.freeze.enabled {
707            return None;
708        }
709        self.toggle()
710    }
711
712    /// Update the banner position (top/bottom) and request a new capture.
713    pub fn set_banner_position(&mut self, position: BannerPosition) {
714        if self.banner_position != position {
715            self.banner_position = position;
716            if self.freeze.enabled {
717                self.freeze.request_capture();
718            }
719        }
720    }
721
722    /// Check if the state overlay is currently visible.
723    pub fn is_state_overlay_visible(&self) -> bool {
724        matches!(self.freeze.overlay, Some(DebugOverlay::State(_)))
725    }
726
727    /// Get a reference to the underlying freeze state.
728    pub fn freeze(&self) -> &DebugFreeze<A> {
729        &self.freeze
730    }
731
732    /// Get a mutable reference to the underlying freeze state.
733    pub fn freeze_mut(&mut self) -> &mut DebugFreeze<A> {
734        &mut self.freeze
735    }
736
737    /// Log an action to the action log.
738    ///
739    /// Call this when dispatching actions to record them for the debug overlay.
740    pub fn log_action<T: tui_dispatch_core::ActionParams>(&mut self, action: &T) {
741        if self.active {
742            self.action_log.log(action);
743        }
744    }
745
746    /// Get the action log.
747    pub fn action_log(&self) -> &ActionLog {
748        &self.action_log
749    }
750
751    /// Render with automatic debug handling.
752    ///
753    /// When debug mode is disabled, simply calls `render_fn` with the full frame area.
754    /// When enabled, captures/paints the frozen snapshot and renders debug overlay.
755    pub fn render<F>(&mut self, frame: &mut Frame, render_fn: F)
756    where
757        F: FnOnce(&mut Frame, Rect),
758    {
759        self.render_with_state(frame, |frame, area, _wants_state| {
760            render_fn(frame, area);
761            None
762        });
763    }
764
765    /// Render with optional state capture for the state overlay.
766    ///
767    /// `render_fn` receives the frame, app area, and a `wants_state` hint that
768    /// is `true` when debug mode is active and state data may be requested.
769    /// Return `Some(DebugTableOverlay)` to update the cached state overlay.
770    pub fn render_with_state<F>(&mut self, frame: &mut Frame, render_fn: F)
771    where
772        F: FnOnce(&mut Frame, Rect, bool) -> Option<DebugTableOverlay>,
773    {
774        let screen = frame.area();
775
776        // Inactive or not in debug mode: just render normally
777        if !self.active || !self.freeze.enabled {
778            let _ = render_fn(frame, screen, false);
779            return;
780        }
781
782        // Debug mode: reserve line for banner
783        let (app_area, banner_area) = self.split_for_banner(screen);
784
785        if self.freeze.pending_capture || self.freeze.snapshot.is_none() {
786            // Capture mode: render app, then capture
787            let state_snapshot = render_fn(frame, app_area, true);
788            self.state_snapshot = state_snapshot;
789            if let Some(ref table) = self.state_snapshot {
790                if self.is_state_overlay_visible() {
791                    self.set_state_overlay(table.clone());
792                }
793            }
794            let buffer_clone = frame.buffer_mut().clone();
795            self.freeze.capture(&buffer_clone);
796        } else if let Some(ref snapshot) = self.freeze.snapshot {
797            // Frozen: paint snapshot
798            paint_snapshot(frame, snapshot);
799        }
800
801        // Render debug overlay
802        self.render_debug_overlay(frame, app_area, banner_area);
803    }
804
805    /// Render with a DebugState reference and automatic state table generation.
806    ///
807    /// This is a convenience wrapper around `render_with_state`.
808    pub fn render_state<S: DebugState, F>(&mut self, frame: &mut Frame, state: &S, render_fn: F)
809    where
810        F: FnOnce(&mut Frame, Rect),
811    {
812        self.render_with_state(frame, |frame, area, wants_state| {
813            render_fn(frame, area);
814            if wants_state {
815                Some(state.build_debug_table("Application State"))
816            } else {
817                None
818            }
819        });
820    }
821
822    /// Split area for manual layout control.
823    pub fn split_area(&self, area: Rect) -> (Rect, Rect) {
824        if !self.freeze.enabled {
825            return (area, Rect::ZERO);
826        }
827        self.split_for_banner(area)
828    }
829
830    /// Check if debug layer intercepts an event.
831    ///
832    /// Call this before your normal event handling. If it returns `true`,
833    /// the event was consumed by the debug layer.
834    ///
835    /// # Example
836    ///
837    /// ```ignore
838    /// if debug.intercepts(&event) {
839    ///     continue;
840    /// }
841    /// // Normal event handling
842    /// ```
843    pub fn intercepts(&mut self, event: &tui_dispatch_core::EventKind) -> bool {
844        self.intercepts_with_effects(event).is_some()
845    }
846
847    /// Handle a debug event with a single call and return a summary outcome.
848    pub fn handle_event(&mut self, event: &tui_dispatch_core::EventKind) -> DebugOutcome<A> {
849        self.handle_event_internal::<()>(event, None)
850    }
851
852    /// Handle a debug event with access to state (for the state overlay).
853    pub fn handle_event_with_state<S: DebugState + 'static>(
854        &mut self,
855        event: &tui_dispatch_core::EventKind,
856        state: &S,
857    ) -> DebugOutcome<A> {
858        self.handle_event_internal(event, Some(state))
859    }
860
861    /// Check if debug layer intercepts an event, returning any side effects.
862    ///
863    /// Returns `None` if the event was not consumed, `Some(effects)` if it was.
864    pub fn intercepts_with_effects(
865        &mut self,
866        event: &tui_dispatch_core::EventKind,
867    ) -> Option<Vec<DebugSideEffect<A>>> {
868        self.intercepts_with_effects_internal::<()>(event, None)
869    }
870
871    /// Check if debug layer intercepts an event with access to app state.
872    ///
873    /// Use this to populate the state overlay when `S` is pressed.
874    pub fn intercepts_with_effects_and_state<S: DebugState + 'static>(
875        &mut self,
876        event: &tui_dispatch_core::EventKind,
877        state: &S,
878    ) -> Option<Vec<DebugSideEffect<A>>> {
879        self.intercepts_with_effects_internal(event, Some(state))
880    }
881
882    /// Check if debug layer intercepts an event with access to app state.
883    pub fn intercepts_with_state<S: DebugState + 'static>(
884        &mut self,
885        event: &tui_dispatch_core::EventKind,
886        state: &S,
887    ) -> bool {
888        self.intercepts_with_effects_internal(event, Some(state))
889            .is_some()
890    }
891
892    fn handle_event_internal<S: DebugState + 'static>(
893        &mut self,
894        event: &tui_dispatch_core::EventKind,
895        state: Option<&S>,
896    ) -> DebugOutcome<A> {
897        let effects = self.intercepts_with_effects_internal(event, state);
898        let Some(effects) = effects else {
899            return DebugOutcome::ignored();
900        };
901
902        let mut queued_actions = Vec::new();
903        for effect in effects {
904            if let DebugSideEffect::ProcessQueuedActions(actions) = effect {
905                queued_actions.extend(actions);
906            }
907        }
908
909        DebugOutcome::consumed(queued_actions)
910    }
911
912    fn intercepts_with_effects_internal<S: DebugState + 'static>(
913        &mut self,
914        event: &tui_dispatch_core::EventKind,
915        state: Option<&S>,
916    ) -> Option<Vec<DebugSideEffect<A>>> {
917        if !self.active {
918            return None;
919        }
920
921        use tui_dispatch_core::EventKind;
922
923        match event {
924            EventKind::Key(key) => self.handle_key_event(*key, state),
925            EventKind::Mouse(mouse) => {
926                if !self.freeze.enabled {
927                    return None;
928                }
929
930                // Only capture mouse when mouse_capture_enabled (toggle with 'i')
931                // When disabled, let terminal handle mouse (allows text selection)
932                if !self.freeze.mouse_capture_enabled {
933                    return None;
934                }
935
936                // Handle click for cell inspection
937                if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
938                    let effect = self.handle_action(DebugAction::InspectCell {
939                        column: mouse.column,
940                        row: mouse.row,
941                    });
942                    return Some(effect.into_iter().collect());
943                }
944
945                // Consume mouse events when capturing
946                Some(vec![])
947            }
948            EventKind::Scroll {
949                delta,
950                column,
951                row,
952                modifiers,
953            } => {
954                if !self.freeze.enabled {
955                    return None;
956                }
957
958                match self.freeze.overlay.as_ref() {
959                    Some(DebugOverlay::ActionLog(_)) => {
960                        let action = if *delta > 0 {
961                            DebugAction::ActionLogScrollUp
962                        } else {
963                            DebugAction::ActionLogScrollDown
964                        };
965                        self.handle_action(action);
966                    }
967                    Some(DebugOverlay::State(_)) => {
968                        self.sync_state_tree_state();
969                        let filtered = &self.state_tree_filtered;
970                        let selected_id = self.state_tree_selected.as_ref();
971                        let expanded_ids = &self.state_tree_expanded;
972                        let style = self.state_tree_style();
973                        let palette = &self.style;
974                        let render_node =
975                            |ctx: TreeNodeRender<'_, String, String>| -> Line<'static> {
976                                render_state_tree_node(ctx, palette)
977                            };
978                        let props = Self::build_tree_props(
979                            filtered,
980                            selected_id,
981                            expanded_ids,
982                            style,
983                            &render_node,
984                        );
985                        let actions: Vec<_> = self
986                            .state_tree_view
987                            .handle_event(
988                                &EventKind::Scroll {
989                                    delta: *delta,
990                                    column: *column,
991                                    row: *row,
992                                    modifiers: *modifiers,
993                                },
994                                props,
995                            )
996                            .into_iter()
997                            .collect();
998                        if !actions.is_empty() {
999                            self.apply_state_tree_actions(actions);
1000                        }
1001                    }
1002                    Some(DebugOverlay::Inspect(table)) => {
1003                        if *delta > 0 {
1004                            self.table_scroll.scroll_up();
1005                        } else {
1006                            self.table_scroll.scroll_down(table.rows.len());
1007                        }
1008                    }
1009                    Some(DebugOverlay::ActionDetail(detail)) => {
1010                        let params_lines = self.detail_params_lines(detail);
1011                        if *delta > 0 {
1012                            self.detail_scroll.scroll_up();
1013                        } else {
1014                            self.detail_scroll.scroll_down(params_lines.len());
1015                        }
1016                    }
1017                    Some(DebugOverlay::StateDetail(detail)) => {
1018                        let line_count = self.state_detail_lines(detail).len();
1019                        if *delta > 0 {
1020                            self.detail_scroll.scroll_up();
1021                        } else {
1022                            self.detail_scroll.scroll_down(line_count);
1023                        }
1024                    }
1025                    Some(DebugOverlay::ComponentDetail(detail)) => {
1026                        let line_count = self.component_detail_lines(detail).len();
1027                        if *delta > 0 {
1028                            self.detail_scroll.scroll_up();
1029                        } else {
1030                            self.detail_scroll.scroll_down(line_count);
1031                        }
1032                    }
1033                    Some(DebugOverlay::Components(_)) => {
1034                        self.sync_components_tree_state();
1035                        let filtered = &self.components_tree_filtered;
1036                        let selected_id = self.components_tree_selected.as_ref();
1037                        let expanded_ids = &self.components_tree_expanded;
1038                        let style = self.state_tree_style();
1039                        let palette = &self.style;
1040                        let render_node =
1041                            |ctx: TreeNodeRender<'_, String, String>| -> Line<'static> {
1042                                render_state_tree_node(ctx, palette)
1043                            };
1044                        let props = Self::build_tree_props(
1045                            filtered,
1046                            selected_id,
1047                            expanded_ids,
1048                            style,
1049                            &render_node,
1050                        );
1051                        let actions: Vec<_> = self
1052                            .components_tree_view
1053                            .handle_event(
1054                                &EventKind::Scroll {
1055                                    delta: *delta,
1056                                    column: *column,
1057                                    row: *row,
1058                                    modifiers: *modifiers,
1059                                },
1060                                props,
1061                            )
1062                            .into_iter()
1063                            .collect();
1064                        if !actions.is_empty() {
1065                            self.apply_components_tree_actions(actions);
1066                        }
1067                    }
1068                    _ => {}
1069                }
1070
1071                Some(vec![])
1072            }
1073            // Don't intercept resize or tick events
1074            EventKind::Resize(_, _) | EventKind::Tick => None,
1075        }
1076    }
1077
1078    /// Show state overlay using a DebugState implementor.
1079    pub fn show_state_overlay<S: DebugState>(&mut self, state: &S) {
1080        let table = state.build_debug_table("Application State");
1081        self.set_state_overlay(table);
1082    }
1083
1084    /// Show action log overlay.
1085    pub fn show_action_log(&mut self) {
1086        let overlay = ActionLogOverlay::from_log(&self.action_log, "Action Log");
1087        self.freeze.set_overlay(DebugOverlay::ActionLog(overlay));
1088    }
1089
1090    /// Show the components overlay (snapshots all mounted components).
1091    pub fn show_components(&mut self) {
1092        use super::table::{ComponentSnapshot, ComponentsOverlay};
1093        let components: Vec<ComponentSnapshot> = self
1094            .component_snapshotter
1095            .as_ref()
1096            .map(|f| f())
1097            .unwrap_or_default();
1098        let overlay = ComponentsOverlay::new("Components", components);
1099        self.set_components_overlay(overlay);
1100    }
1101
1102    /// Queue an action to be processed when debug mode is disabled.
1103    pub fn queue_action(&mut self, action: A) {
1104        self.freeze.queue(action);
1105    }
1106
1107    /// Take any queued actions (from task manager resume).
1108    ///
1109    /// Call this after `intercepts()` returns effects to get queued actions
1110    /// that should be dispatched.
1111    pub fn take_queued_actions(&mut self) -> Vec<A> {
1112        std::mem::take(&mut self.freeze.queued_actions)
1113    }
1114
1115    // =========================================================================
1116    // Private helpers
1117    // =========================================================================
1118
1119    fn set_state_overlay(&mut self, table: DebugTableOverlay) {
1120        let is_new_overlay = !matches!(
1121            self.freeze.overlay,
1122            Some(DebugOverlay::State(_)) | Some(DebugOverlay::StateDetail(_))
1123        );
1124        if is_new_overlay {
1125            self.table_scroll.reset();
1126            self.state_tree_view = TreeView::new();
1127            self.state_tree_selected = None;
1128            self.state_tree_expanded.clear();
1129            self.state_search_query.clear();
1130            self.state_search_active = false;
1131        }
1132
1133        self.state_tree_nodes = self.build_state_tree_nodes(&table);
1134        if is_new_overlay {
1135            self.state_tree_expanded = self
1136                .state_tree_nodes
1137                .iter()
1138                .map(|node| node.id.clone())
1139                .collect();
1140        }
1141        self.refilter_state_tree();
1142        self.sync_state_tree_state();
1143        self.state_snapshot = Some(table.clone());
1144        self.freeze.set_overlay(DebugOverlay::State(table));
1145    }
1146
1147    fn build_state_tree_nodes(&self, table: &DebugTableOverlay) -> Vec<TreeNode<String, String>> {
1148        let mut sections: Vec<TreeNode<String, String>> = Vec::new();
1149        let mut current: Option<TreeNode<String, String>> = None;
1150        let mut section_index = 0usize;
1151        let mut entry_index = 0usize;
1152        let mut current_section_index = 0usize;
1153
1154        for row in &table.rows {
1155            match row {
1156                DebugTableRow::Section(title) => {
1157                    if let Some(section) = current.take() {
1158                        sections.push(section);
1159                    }
1160                    current_section_index = section_index;
1161                    entry_index = 0;
1162                    let id = format!("section:{section_index}:{title}");
1163                    section_index = section_index.saturating_add(1);
1164                    current = Some(TreeNode::with_children(id, title.clone(), Vec::new()));
1165                }
1166                DebugTableRow::Entry { key, value } => {
1167                    if current.is_none() {
1168                        current_section_index = section_index;
1169                        entry_index = 0;
1170                        let id = format!("section:{section_index}:State");
1171                        section_index = section_index.saturating_add(1);
1172                        current =
1173                            Some(TreeNode::with_children(id, "State".to_string(), Vec::new()));
1174                    }
1175                    if let Some(section) = current.as_mut() {
1176                        let entry_id = format!("entry:{current_section_index}:{entry_index}:{key}");
1177                        entry_index = entry_index.saturating_add(1);
1178                        section
1179                            .children
1180                            .push(TreeNode::new(entry_id, format!("{key}: {value}")));
1181                    }
1182                }
1183            }
1184        }
1185
1186        if let Some(section) = current.take() {
1187            sections.push(section);
1188        }
1189
1190        sections
1191    }
1192
1193    /// Build a detail view for the currently selected state tree entry.
1194    fn build_state_entry_detail(
1195        &self,
1196        selected_id: &str,
1197    ) -> Option<super::table::StateEntryDetail> {
1198        // Only entry nodes (leaves) have details
1199        if !selected_id.starts_with("entry:") {
1200            return None;
1201        }
1202
1203        // Find the node in the tree to get the full value
1204        for section_node in &self.state_tree_nodes {
1205            for child in &section_node.children {
1206                if child.id == selected_id {
1207                    // Node value is "key: value"
1208                    let (key, value) = child.value.split_once(": ").unwrap_or((&child.value, ""));
1209                    return Some(super::table::StateEntryDetail {
1210                        section: section_node.value.clone(),
1211                        key: key.to_string(),
1212                        value: value.to_string(),
1213                    });
1214                }
1215            }
1216        }
1217        None
1218    }
1219
1220    fn build_components_tree_nodes(
1221        components: &[super::table::ComponentSnapshot],
1222    ) -> Vec<TreeNode<String, String>> {
1223        components
1224            .iter()
1225            .enumerate()
1226            .map(|(i, comp)| {
1227                let id = format!("comp:{i}");
1228                let bound = comp
1229                    .bound_id
1230                    .as_deref()
1231                    .map(|b| format!(" \u{2192} {b}")) // → arrow
1232                    .unwrap_or_default();
1233                let area_str = comp
1234                    .last_area
1235                    .map(|a| format!(" [{}x{} @ ({},{})]", a.width, a.height, a.x, a.y))
1236                    .unwrap_or_default();
1237                let label = format!("{}{}{}", comp.type_name, bound, area_str);
1238
1239                let children: Vec<TreeNode<String, String>> = comp
1240                    .debug_entries
1241                    .iter()
1242                    .enumerate()
1243                    .map(|(j, (k, v))| {
1244                        TreeNode::new(format!("entry:{i}:{j}:{k}"), format!("{k}: {v}"))
1245                    })
1246                    .collect();
1247
1248                TreeNode::with_children(id, label, children)
1249            })
1250            .collect()
1251    }
1252
1253    fn set_components_overlay(&mut self, overlay: super::table::ComponentsOverlay) {
1254        let is_new = !matches!(
1255            self.freeze.overlay,
1256            Some(DebugOverlay::Components(_)) | Some(DebugOverlay::ComponentDetail(_))
1257        );
1258        if is_new {
1259            self.components_tree_view = TreeView::new();
1260            self.components_tree_selected = None;
1261            self.components_tree_expanded.clear();
1262            self.components_search_query.clear();
1263            self.components_search_active = false;
1264        }
1265
1266        self.components_tree_nodes = Self::build_components_tree_nodes(&overlay.components);
1267
1268        if is_new {
1269            // Auto-expand all components by default
1270            self.components_tree_expanded = self
1271                .components_tree_nodes
1272                .iter()
1273                .map(|node| node.id.clone())
1274                .collect();
1275        }
1276
1277        self.refilter_components_tree();
1278        self.freeze.set_overlay(DebugOverlay::Components(overlay));
1279    }
1280
1281    fn refilter_state_tree(&mut self) {
1282        self.state_tree_filtered =
1283            filter_tree_nodes(&self.state_tree_nodes, &self.state_search_query);
1284
1285        let filtered = &self.state_tree_filtered;
1286        let selected_valid = self
1287            .state_tree_selected
1288            .as_deref()
1289            .map(|id| Self::tree_contains_id(filtered, id))
1290            .unwrap_or(false);
1291        if !selected_valid {
1292            self.state_tree_selected = filtered.first().map(|node| node.id.clone());
1293        }
1294    }
1295
1296    fn refilter_components_tree(&mut self) {
1297        self.components_tree_filtered =
1298            filter_tree_nodes(&self.components_tree_nodes, &self.components_search_query);
1299
1300        let filtered = &self.components_tree_filtered;
1301        let selected_valid = self
1302            .components_tree_selected
1303            .as_deref()
1304            .map(|id| Self::tree_contains_id(filtered, id))
1305            .unwrap_or(false);
1306        if !selected_valid {
1307            self.components_tree_selected = filtered.first().map(|node| node.id.clone());
1308        }
1309    }
1310
1311    fn sync_state_tree_state(&mut self) {
1312        let nodes = &self.state_tree_nodes;
1313        self.state_tree_expanded
1314            .retain(|id| Self::tree_contains_id(nodes, id));
1315
1316        let filtered = &self.state_tree_filtered;
1317        let selected_valid = self
1318            .state_tree_selected
1319            .as_deref()
1320            .map(|id| Self::tree_contains_id(filtered, id))
1321            .unwrap_or(false);
1322
1323        if !selected_valid {
1324            self.state_tree_selected = filtered.first().map(|node| node.id.clone());
1325        }
1326    }
1327
1328    fn tree_contains_id(nodes: &[TreeNode<String, String>], id: &str) -> bool {
1329        nodes
1330            .iter()
1331            .any(|node| node.id == id || Self::tree_contains_id(&node.children, id))
1332    }
1333
1334    fn state_tree_style(&self) -> TreeViewStyle {
1335        TreeViewStyle {
1336            base: BaseStyle {
1337                border: None,
1338                padding: Padding::default(),
1339                bg: Some(self.style.overlay_bg),
1340                fg: Some(self.style.text_primary),
1341            },
1342            selection: SelectionStyle::disabled(),
1343            scrollbar: self.component_scrollbar_style(),
1344            branches: TreeBranchStyle {
1345                mode: TreeBranchMode::Branch,
1346                connector_style: Style::default().fg(self.style.text_secondary),
1347                ..Default::default()
1348            },
1349        }
1350    }
1351
1352    fn build_tree_props<'a>(
1353        nodes: &'a [TreeNode<String, String>],
1354        selected_id: Option<&'a String>,
1355        expanded_ids: &'a HashSet<String>,
1356        style: TreeViewStyle,
1357        render_node: &'a dyn Fn(TreeNodeRender<'_, String, String>) -> Line<'static>,
1358    ) -> TreeViewProps<'a, String, String, StateTreeAction> {
1359        TreeViewProps {
1360            nodes,
1361            selected_id,
1362            expanded_ids,
1363            is_focused: true,
1364            style,
1365            behavior: TreeViewBehavior::default(),
1366            measure_node: None,
1367            column_padding: 0,
1368            on_select: Rc::new(|id| state_tree_select(id)),
1369            on_toggle: Rc::new(|id, expanded| state_tree_toggle(id, expanded)),
1370            render_node,
1371        }
1372    }
1373
1374    fn apply_state_tree_actions(&mut self, actions: impl IntoIterator<Item = StateTreeAction>) {
1375        for action in actions {
1376            match action {
1377                StateTreeAction::Select(id) => {
1378                    self.state_tree_selected = Some(id);
1379                }
1380                StateTreeAction::Toggle(id, expand) => {
1381                    if expand {
1382                        self.state_tree_expanded.insert(id);
1383                    } else {
1384                        self.state_tree_expanded.remove(&id);
1385                    }
1386                }
1387            }
1388        }
1389    }
1390
1391    fn apply_components_tree_actions(
1392        &mut self,
1393        actions: impl IntoIterator<Item = StateTreeAction>,
1394    ) {
1395        for action in actions {
1396            match action {
1397                StateTreeAction::Select(id) => {
1398                    self.components_tree_selected = Some(id);
1399                }
1400                StateTreeAction::Toggle(id, expand) => {
1401                    if expand {
1402                        self.components_tree_expanded.insert(id);
1403                    } else {
1404                        self.components_tree_expanded.remove(&id);
1405                    }
1406                }
1407            }
1408        }
1409    }
1410
1411    fn save_state_snapshot<S: DebugState + 'static>(&mut self, state: Option<&S>) {
1412        let Some(state) = state else {
1413            self.freeze
1414                .set_message("State unavailable: call render_state() first");
1415            return;
1416        };
1417
1418        let path = self.state_snapshot_path();
1419
1420        let result = if let Some(snapshotter) = self.state_snapshotter.as_ref() {
1421            snapshotter(state as &dyn Any, &path)
1422        } else {
1423            let snapshot = DebugStateSnapshot::from_state(state, "Application State");
1424            crate::save_json(&path, &snapshot)
1425        };
1426
1427        match result {
1428            Ok(()) => self
1429                .freeze
1430                .set_message(format!("Saved state: {}", path.display())),
1431            Err(err) => self
1432                .freeze
1433                .set_message(format!("State save failed: {err:?}")),
1434        }
1435    }
1436
1437    fn state_snapshot_path(&self) -> PathBuf {
1438        let timestamp = SystemTime::now()
1439            .duration_since(UNIX_EPOCH)
1440            .unwrap_or_default()
1441            .as_millis();
1442        PathBuf::from(format!("debug-state-{timestamp}.json"))
1443    }
1444
1445    fn overlay_modal_area(&self, app_area: Rect) -> Rect {
1446        let modal_width = (app_area.width * 80 / 100)
1447            .clamp(40, 160)
1448            .min(app_area.width);
1449        let modal_height = (app_area.height * 80 / 100)
1450            .clamp(12, 50)
1451            .min(app_area.height);
1452        centered_rect(modal_width, modal_height, app_area)
1453    }
1454
1455    fn overlay_modal_style(&self) -> ModalStyle {
1456        ModalStyle {
1457            dim_factor: 0.0,
1458            base: BaseStyle {
1459                border: None,
1460                padding: Padding::default(), // No outer padding - title/footer at edges
1461                bg: Some(self.style.overlay_bg),
1462                fg: None,
1463            },
1464        }
1465    }
1466
1467    fn render_overlay_container<F>(
1468        &self,
1469        frame: &mut Frame,
1470        app_area: Rect,
1471        title: &str,
1472        hints: Option<ModalHints>,
1473        footer_center_items: Option<Vec<StatusBarItem<'static>>>,
1474        mut render_body: F,
1475    ) where
1476        F: FnMut(&mut Frame, Rect),
1477    {
1478        let modal_area = self.overlay_modal_area(app_area);
1479        let mut modal = Modal::new();
1480        let mut render_content = |frame: &mut Frame, content_area: Rect| {
1481            if content_area.height == 0 || content_area.width == 0 {
1482                return;
1483            }
1484
1485            // Render title bar (top)
1486            let content_area = self.render_overlay_title(frame, content_area, title);
1487            if content_area.height == 0 || content_area.width == 0 {
1488                return;
1489            }
1490
1491            // Render footer bar (bottom) if hints provided
1492            let content_area = if let Some(hints) = hints {
1493                self.render_overlay_footer(
1494                    frame,
1495                    content_area,
1496                    hints,
1497                    footer_center_items.as_deref(),
1498                )
1499            } else {
1500                content_area
1501            };
1502            if content_area.height == 0 || content_area.width == 0 {
1503                return;
1504            }
1505
1506            // Apply inner padding to body content only (not title/footer)
1507            let body_area = Rect {
1508                x: content_area.x.saturating_add(1),
1509                y: content_area.y,
1510                width: content_area.width.saturating_sub(2),
1511                height: content_area.height,
1512            };
1513            if body_area.width == 0 {
1514                return;
1515            }
1516
1517            render_body(frame, body_area);
1518        };
1519
1520        modal.render(
1521            frame,
1522            app_area,
1523            ModalProps {
1524                is_open: true,
1525                is_focused: false,
1526                area: modal_area,
1527                style: self.overlay_modal_style(),
1528                behavior: ModalBehavior::default(),
1529                on_close: Rc::new(|| ()),
1530                render_content: &mut render_content,
1531            },
1532        );
1533    }
1534
1535    fn render_overlay_title(&self, frame: &mut Frame, area: Rect, title: &str) -> Rect {
1536        if area.height == 0 || area.width == 0 {
1537            return area;
1538        }
1539
1540        use ratatui::text::Span;
1541
1542        let accent = self.style.accent;
1543        let title_style = Style::default().fg(accent).add_modifier(Modifier::BOLD);
1544        let title_items = [StatusBarItem::span(Span::styled(
1545            format!(" {title} "),
1546            title_style,
1547        ))];
1548        let title_bar_style = StatusBarStyle {
1549            base: BaseStyle {
1550                border: None,
1551                padding: Padding::default(),
1552                bg: None,
1553                fg: None,
1554            },
1555            text: Style::default().fg(accent),
1556            hint_key: title_style,
1557            hint_label: Style::default().fg(accent),
1558            separator: Style::default().fg(accent),
1559        };
1560        let title_area = Rect { height: 1, ..area };
1561
1562        let mut status_bar = StatusBar::new();
1563        <StatusBar as tui_dispatch_core::Component<()>>::render(
1564            &mut status_bar,
1565            frame,
1566            title_area,
1567            StatusBarProps {
1568                left: StatusBarSection::empty(),
1569                center: StatusBarSection::items(&title_items),
1570                right: StatusBarSection::empty(),
1571                style: title_bar_style,
1572                is_focused: false,
1573            },
1574        );
1575
1576        Rect {
1577            x: area.x,
1578            y: area.y.saturating_add(title_area.height),
1579            width: area.width,
1580            height: area.height.saturating_sub(title_area.height),
1581        }
1582    }
1583
1584    /// Render a modal footer status bar with hints.
1585    /// Returns the remaining content area (above the footer).
1586    fn render_overlay_footer(
1587        &self,
1588        frame: &mut Frame,
1589        area: Rect,
1590        hints: ModalHints,
1591        center_items: Option<&[StatusBarItem<'static>]>,
1592    ) -> Rect {
1593        if area.height < 2 || area.width == 0 {
1594            return area;
1595        }
1596
1597        let footer_area = Rect {
1598            x: area.x,
1599            y: area.y.saturating_add(area.height.saturating_sub(1)),
1600            width: area.width,
1601            height: 1,
1602        };
1603
1604        // Key style: bold, dark text on muted background
1605        let key_style = Style::default()
1606            .fg(self.style.bg_deep)
1607            .bg(self.style.text_secondary)
1608            .add_modifier(Modifier::BOLD);
1609        let label_style = Style::default().fg(self.style.text_secondary);
1610
1611        // Build left items from hints
1612        let mut left_items: Vec<StatusBarItem<'static>> = Vec::new();
1613        for hint in hints.left {
1614            left_items.push(StatusBarItem::span(Span::styled(
1615                format!(" {} ", hint.key),
1616                key_style,
1617            )));
1618            left_items.push(StatusBarItem::span(Span::styled(
1619                format!(" {} ", hint.label),
1620                label_style,
1621            )));
1622        }
1623
1624        // Build right items from hints
1625        let mut right_items: Vec<StatusBarItem<'static>> = Vec::new();
1626        for hint in hints.right {
1627            right_items.push(StatusBarItem::span(Span::styled(
1628                format!(" {} ", hint.key),
1629                key_style,
1630            )));
1631            right_items.push(StatusBarItem::span(Span::styled(
1632                format!(" {} ", hint.label),
1633                label_style,
1634            )));
1635        }
1636
1637        let footer_style = StatusBarStyle {
1638            base: BaseStyle {
1639                border: None,
1640                padding: Padding::default(),
1641                bg: Some(self.style.overlay_bg_dark),
1642                fg: None,
1643            },
1644            text: label_style,
1645            hint_key: key_style,
1646            hint_label: label_style,
1647            separator: label_style,
1648        };
1649
1650        let left = StatusBarSection::items(&left_items).with_separator("");
1651        let center = if let Some(items) = center_items {
1652            StatusBarSection::items(items).with_separator("")
1653        } else {
1654            StatusBarSection::empty()
1655        };
1656        let right = if right_items.is_empty() {
1657            StatusBarSection::empty()
1658        } else {
1659            StatusBarSection::items(&right_items).with_separator("")
1660        };
1661
1662        let mut status_bar = StatusBar::new();
1663        <StatusBar as tui_dispatch_core::Component<()>>::render(
1664            &mut status_bar,
1665            frame,
1666            footer_area,
1667            StatusBarProps {
1668                left,
1669                center,
1670                right,
1671                style: footer_style,
1672                is_focused: false,
1673            },
1674        );
1675
1676        // Return area above footer
1677        Rect {
1678            x: area.x,
1679            y: area.y,
1680            width: area.width,
1681            height: area.height.saturating_sub(1),
1682        }
1683    }
1684
1685    fn component_scrollbar_style(&self) -> ComponentScrollbarStyle {
1686        let style = &self.style.scrollbar;
1687        ComponentScrollbarStyle {
1688            thumb: style.thumb,
1689            track: style.track,
1690            begin: style.begin,
1691            end: style.end,
1692            thumb_symbol: style.thumb_symbol,
1693            track_symbol: style.track_symbol,
1694            begin_symbol: style.begin_symbol,
1695            end_symbol: style.end_symbol,
1696        }
1697    }
1698
1699    fn handle_key_event<S: DebugState + 'static>(
1700        &mut self,
1701        key: KeyEvent,
1702        state: Option<&S>,
1703    ) -> Option<Vec<DebugSideEffect<A>>> {
1704        // Toggle key always works (even when disabled)
1705        if key.code == self.toggle_key && key.modifiers.is_empty() {
1706            let effect = self.toggle();
1707            return Some(effect.into_iter().collect());
1708        }
1709
1710        // Other commands only work when enabled
1711        if !self.freeze.enabled {
1712            return None;
1713        }
1714
1715        // Handle action-log specific search/navigation first.
1716        // We intentionally do a non-borrowing presence check before taking a mutable
1717        // overlay reference, so we can still call other `&mut self` helpers in this
1718        // function without extending the `log` borrow across the whole match block.
1719        if matches!(self.freeze.overlay, Some(DebugOverlay::ActionLog(_))) {
1720            let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay else {
1721                return Some(vec![]);
1722            };
1723
1724            if log.search_input_active {
1725                let is_text_input_char = !key.modifiers.contains(KeyModifiers::CONTROL)
1726                    && !key.modifiers.contains(KeyModifiers::ALT);
1727                match key.code {
1728                    KeyCode::Esc | KeyCode::Enter => {
1729                        log.search_input_active = false;
1730                        return Some(vec![]);
1731                    }
1732                    KeyCode::Backspace => {
1733                        if !log.pop_search_char() {
1734                            log.search_input_active = false;
1735                        }
1736                        return Some(vec![]);
1737                    }
1738                    KeyCode::Char(c) if is_text_input_char => {
1739                        log.push_search_char(c);
1740                        return Some(vec![]);
1741                    }
1742                    _ => {
1743                        return Some(vec![]);
1744                    }
1745                }
1746            }
1747
1748            // Backspace closes this overlay when not editing search query.
1749            if key.code == KeyCode::Backspace {
1750                self.handle_action(DebugAction::CloseOverlay);
1751                return Some(vec![]);
1752            }
1753
1754            if key.code == KeyCode::Char('/') {
1755                log.search_input_active = true;
1756                return Some(vec![]);
1757            }
1758
1759            // Crossterm normalizes Shift+n to `Char('N')`.
1760            let prev_match = key.code == KeyCode::Char('N');
1761            if prev_match {
1762                log.search_prev();
1763                return Some(vec![]);
1764            }
1765
1766            if key.code == KeyCode::Char('n') {
1767                log.search_next();
1768                return Some(vec![]);
1769            }
1770
1771            let action = match key.code {
1772                KeyCode::Char('j') | KeyCode::Down => Some(DebugAction::ActionLogScrollDown),
1773                KeyCode::Char('k') | KeyCode::Up => Some(DebugAction::ActionLogScrollUp),
1774                KeyCode::Char('g') => Some(DebugAction::ActionLogScrollTop),
1775                KeyCode::Char('G') => Some(DebugAction::ActionLogScrollBottom),
1776                KeyCode::PageDown => Some(DebugAction::ActionLogPageDown),
1777                KeyCode::PageUp => Some(DebugAction::ActionLogPageUp),
1778                KeyCode::Enter => Some(DebugAction::ActionLogShowDetail),
1779                _ => None,
1780            };
1781            if let Some(action) = action {
1782                self.handle_action(action);
1783                return Some(vec![]);
1784            }
1785        }
1786
1787        // Handle search input for state / components overlays (before global shortcuts).
1788        if matches!(
1789            self.freeze.overlay,
1790            Some(DebugOverlay::State(_)) | Some(DebugOverlay::Components(_))
1791        ) {
1792            let (search_active, is_state) =
1793                if matches!(self.freeze.overlay, Some(DebugOverlay::State(_))) {
1794                    (self.state_search_active, true)
1795                } else {
1796                    (self.components_search_active, false)
1797                };
1798
1799            if search_active {
1800                let is_text = !key.modifiers.contains(KeyModifiers::CONTROL)
1801                    && !key.modifiers.contains(KeyModifiers::ALT);
1802                match key.code {
1803                    KeyCode::Esc | KeyCode::Enter => {
1804                        if is_state {
1805                            self.state_search_active = false;
1806                        } else {
1807                            self.components_search_active = false;
1808                        }
1809                        return Some(vec![]);
1810                    }
1811                    KeyCode::Backspace => {
1812                        let query = if is_state {
1813                            &mut self.state_search_query
1814                        } else {
1815                            &mut self.components_search_query
1816                        };
1817                        if query.pop().is_none() {
1818                            if is_state {
1819                                self.state_search_active = false;
1820                            } else {
1821                                self.components_search_active = false;
1822                            }
1823                        }
1824                        if is_state {
1825                            self.refilter_state_tree();
1826                        } else {
1827                            self.refilter_components_tree();
1828                        }
1829                        return Some(vec![]);
1830                    }
1831                    KeyCode::Char(c) if is_text => {
1832                        if is_state {
1833                            self.state_search_query.push(c);
1834                            self.refilter_state_tree();
1835                        } else {
1836                            self.components_search_query.push(c);
1837                            self.refilter_components_tree();
1838                        }
1839                        return Some(vec![]);
1840                    }
1841                    _ => return Some(vec![]),
1842                }
1843            }
1844
1845            if key.code == KeyCode::Char('/') {
1846                if is_state {
1847                    self.state_search_active = true;
1848                } else {
1849                    self.components_search_active = true;
1850                }
1851                return Some(vec![]);
1852            }
1853        }
1854
1855        match key.code {
1856            KeyCode::Char('b') | KeyCode::Char('B') => {
1857                self.banner_position = self.banner_position.toggle();
1858                self.freeze.request_capture();
1859                return Some(vec![]);
1860            }
1861            KeyCode::Char('s') | KeyCode::Char('S') => {
1862                if matches!(self.freeze.overlay, Some(DebugOverlay::State(_))) {
1863                    self.freeze.clear_overlay();
1864                } else if let Some(state) = state {
1865                    let table = state.build_debug_table("Application State");
1866                    self.set_state_overlay(table);
1867                } else if let Some(ref table) = self.state_snapshot {
1868                    self.set_state_overlay(table.clone());
1869                } else {
1870                    let table = DebugTableBuilder::new()
1871                        .section("State")
1872                        .entry(
1873                            "hint",
1874                            "Press 's' after providing state via render_with_state() or show_state_overlay()",
1875                        )
1876                        .finish("Application State");
1877                    self.freeze.set_overlay(DebugOverlay::State(table));
1878                }
1879                return Some(vec![]);
1880            }
1881            KeyCode::Char('w') | KeyCode::Char('W') => {
1882                self.save_state_snapshot(state);
1883                return Some(vec![]);
1884            }
1885            _ => {}
1886        }
1887
1888        // Handle internal debug commands (hardcoded keys).
1889        // Note: this runs after action-log search handling so text input in `/`
1890        // mode can accept letters like 'A' without toggling the overlay.
1891        let action = match key.code {
1892            KeyCode::Char('a') | KeyCode::Char('A') => Some(DebugAction::ToggleActionLog),
1893            KeyCode::Char('c') | KeyCode::Char('C') => Some(DebugAction::ToggleComponents),
1894            KeyCode::Char('y') | KeyCode::Char('Y') => Some(DebugAction::CopyFrame),
1895            KeyCode::Char('i') | KeyCode::Char('I') => Some(DebugAction::ToggleMouseCapture),
1896            KeyCode::Char('q') | KeyCode::Char('Q') => Some(DebugAction::CloseOverlay),
1897            _ => None,
1898        };
1899
1900        if let Some(action) = action {
1901            let effect = self.handle_action(action);
1902            return Some(effect.into_iter().collect());
1903        }
1904
1905        // Handle remaining overlay-specific navigation.
1906        match &self.freeze.overlay {
1907            Some(DebugOverlay::State(_)) => {
1908                if key.code == KeyCode::Backspace {
1909                    self.handle_action(DebugAction::CloseOverlay);
1910                    return Some(vec![]);
1911                }
1912                // Enter on a leaf node opens detail view
1913                if key.code == KeyCode::Enter {
1914                    if let Some(ref id) = self.state_tree_selected {
1915                        if id.starts_with("entry:") {
1916                            self.handle_action(DebugAction::StateTreeShowDetail);
1917                            return Some(vec![]);
1918                        }
1919                    }
1920                }
1921                self.sync_state_tree_state();
1922                let filtered = &self.state_tree_filtered;
1923                let selected_id = self.state_tree_selected.as_ref();
1924                let expanded_ids = &self.state_tree_expanded;
1925                let style = self.state_tree_style();
1926                let palette = &self.style;
1927                let render_node = |ctx: TreeNodeRender<'_, String, String>| -> Line<'static> {
1928                    render_state_tree_node(ctx, palette)
1929                };
1930                let props = Self::build_tree_props(
1931                    filtered,
1932                    selected_id,
1933                    expanded_ids,
1934                    style,
1935                    &render_node,
1936                );
1937                let actions: Vec<_> = self
1938                    .state_tree_view
1939                    .handle_event(&EventKind::Key(key), props)
1940                    .into_iter()
1941                    .collect();
1942                if !actions.is_empty() {
1943                    self.apply_state_tree_actions(actions);
1944                    return Some(vec![]);
1945                }
1946            }
1947            Some(DebugOverlay::Inspect(table)) => {
1948                // Backspace closes this overlay
1949                if key.code == KeyCode::Backspace {
1950                    self.handle_action(DebugAction::CloseOverlay);
1951                    return Some(vec![]);
1952                }
1953                if self
1954                    .table_scroll
1955                    .handle_scroll_key(key.code, table.rows.len())
1956                {
1957                    return Some(vec![]);
1958                }
1959            }
1960            Some(DebugOverlay::ActionDetail(detail)) => {
1961                // Back to action log on Esc, Backspace, or Enter
1962                if matches!(key.code, KeyCode::Esc | KeyCode::Backspace | KeyCode::Enter) {
1963                    self.handle_action(DebugAction::ActionLogBackToList);
1964                    return Some(vec![]);
1965                }
1966
1967                let params_lines = self.detail_params_lines(detail);
1968                if self
1969                    .detail_scroll
1970                    .handle_scroll_key(key.code, params_lines.len())
1971                {
1972                    return Some(vec![]);
1973                }
1974            }
1975            Some(DebugOverlay::Components(_)) => {
1976                if key.code == KeyCode::Backspace {
1977                    self.handle_action(DebugAction::CloseOverlay);
1978                    return Some(vec![]);
1979                }
1980                // Enter on a component node opens detail view
1981                if key.code == KeyCode::Enter {
1982                    if let Some(ref id) = self.components_tree_selected {
1983                        if id.starts_with("comp:") {
1984                            self.handle_action(DebugAction::ComponentShowDetail);
1985                            return Some(vec![]);
1986                        }
1987                    }
1988                }
1989                let filtered = &self.components_tree_filtered;
1990                let selected_id = self.components_tree_selected.as_ref();
1991                let expanded_ids = &self.components_tree_expanded;
1992                let style = self.state_tree_style();
1993                let palette = &self.style;
1994                let render_node = |ctx: TreeNodeRender<'_, String, String>| -> Line<'static> {
1995                    render_state_tree_node(ctx, palette)
1996                };
1997                let props = Self::build_tree_props(
1998                    filtered,
1999                    selected_id,
2000                    expanded_ids,
2001                    style,
2002                    &render_node,
2003                );
2004                let actions: Vec<_> = self
2005                    .components_tree_view
2006                    .handle_event(&EventKind::Key(key), props)
2007                    .into_iter()
2008                    .collect();
2009                if !actions.is_empty() {
2010                    self.apply_components_tree_actions(actions);
2011                    return Some(vec![]);
2012                }
2013            }
2014            Some(DebugOverlay::StateDetail(detail)) => {
2015                if matches!(key.code, KeyCode::Esc | KeyCode::Backspace) {
2016                    self.handle_action(DebugAction::StateTreeBackToTree);
2017                    return Some(vec![]);
2018                }
2019                let line_count = self.state_detail_lines(detail).len();
2020                if self.detail_scroll.handle_scroll_key(key.code, line_count) {
2021                    return Some(vec![]);
2022                }
2023            }
2024            Some(DebugOverlay::ComponentDetail(detail)) => {
2025                if matches!(key.code, KeyCode::Esc | KeyCode::Backspace) {
2026                    self.handle_action(DebugAction::ComponentBackToList);
2027                    return Some(vec![]);
2028                }
2029                let line_count = self.component_detail_lines(detail).len();
2030                if self.detail_scroll.handle_scroll_key(key.code, line_count) {
2031                    return Some(vec![]);
2032                }
2033            }
2034            _ => {}
2035        }
2036
2037        // Esc toggles debug mode unless the active overlay handled it above.
2038        if key.code == KeyCode::Esc {
2039            let effect = self.toggle();
2040            return Some(effect.into_iter().collect());
2041        }
2042
2043        // Consume all key events when debug is enabled
2044        Some(vec![])
2045    }
2046
2047    fn toggle(&mut self) -> Option<DebugSideEffect<A>> {
2048        if self.freeze.enabled {
2049            // Disable: resume tasks/subs
2050            #[cfg(feature = "subscriptions")]
2051            if let Some(ref handle) = self.sub_handle {
2052                handle.resume();
2053            }
2054
2055            #[cfg(feature = "tasks")]
2056            let task_queued = if let Some(ref handle) = self.task_handle {
2057                handle.resume()
2058            } else {
2059                vec![]
2060            };
2061            #[cfg(not(feature = "tasks"))]
2062            let task_queued: Vec<A> = vec![];
2063
2064            let queued = self.freeze.take_queued();
2065            self.freeze.disable();
2066            self.state_snapshot = None;
2067            self.state_tree_nodes.clear();
2068            self.state_tree_filtered.clear();
2069            self.state_tree_selected = None;
2070            self.state_tree_expanded.clear();
2071            self.state_tree_view = TreeView::new();
2072            self.table_scroll.reset();
2073            self.detail_scroll.reset();
2074
2075            // Combine queued actions from freeze and task manager
2076            let mut all_queued = queued;
2077            all_queued.extend(task_queued);
2078
2079            if all_queued.is_empty() {
2080                None
2081            } else {
2082                Some(DebugSideEffect::ProcessQueuedActions(all_queued))
2083            }
2084        } else {
2085            // Enable: pause tasks/subs
2086            #[cfg(feature = "tasks")]
2087            if let Some(ref handle) = self.task_handle {
2088                handle.pause();
2089            }
2090            #[cfg(feature = "subscriptions")]
2091            if let Some(ref handle) = self.sub_handle {
2092                handle.pause();
2093            }
2094            self.freeze.enable();
2095            self.state_snapshot = None;
2096            self.state_tree_nodes.clear();
2097            self.state_tree_filtered.clear();
2098            self.state_tree_selected = None;
2099            self.state_tree_expanded.clear();
2100            self.state_tree_view = TreeView::new();
2101            self.table_scroll.reset();
2102            self.detail_scroll.reset();
2103            None
2104        }
2105    }
2106
2107    fn handle_action(&mut self, action: DebugAction) -> Option<DebugSideEffect<A>> {
2108        match action {
2109            DebugAction::Toggle => self.toggle(),
2110            DebugAction::CopyFrame => {
2111                let text = &self.freeze.snapshot_text;
2112                // Use OSC52 escape sequence to copy to clipboard
2113                let encoded = BASE64_STANDARD.encode(text);
2114                print!("\x1b]52;c;{}\x07", encoded);
2115                std::io::stdout().flush().ok();
2116                self.freeze.set_message("Copied to clipboard");
2117                None
2118            }
2119            DebugAction::ToggleState => {
2120                if matches!(self.freeze.overlay, Some(DebugOverlay::State(_))) {
2121                    self.freeze.clear_overlay();
2122                } else if let Some(ref table) = self.state_snapshot {
2123                    self.set_state_overlay(table.clone());
2124                } else {
2125                    // Show placeholder - user should call show_state_overlay()
2126                    let table = DebugTableBuilder::new()
2127                        .section("State")
2128                        .entry(
2129                            "hint",
2130                            "Press 's' after providing state via render_with_state() or show_state_overlay()",
2131                        )
2132                        .finish("Application State");
2133                    self.freeze.set_overlay(DebugOverlay::State(table));
2134                }
2135                None
2136            }
2137            DebugAction::ToggleActionLog => {
2138                if matches!(self.freeze.overlay, Some(DebugOverlay::ActionLog(_))) {
2139                    self.freeze.clear_overlay();
2140                } else {
2141                    self.show_action_log();
2142                }
2143                None
2144            }
2145            DebugAction::ToggleComponents => {
2146                if matches!(self.freeze.overlay, Some(DebugOverlay::Components(_))) {
2147                    self.freeze.clear_overlay();
2148                } else {
2149                    self.show_components();
2150                }
2151                None
2152            }
2153            DebugAction::ActionLogScrollUp => {
2154                if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
2155                    log.scroll_up();
2156                }
2157                None
2158            }
2159            DebugAction::ActionLogScrollDown => {
2160                if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
2161                    log.scroll_down();
2162                }
2163                None
2164            }
2165            DebugAction::ActionLogScrollTop => {
2166                if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
2167                    log.scroll_to_top();
2168                }
2169                None
2170            }
2171            DebugAction::ActionLogScrollBottom => {
2172                if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
2173                    log.scroll_to_bottom();
2174                }
2175                None
2176            }
2177            DebugAction::ActionLogPageUp => {
2178                if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
2179                    log.page_up(10);
2180                }
2181                None
2182            }
2183            DebugAction::ActionLogPageDown => {
2184                if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
2185                    log.page_down(10);
2186                }
2187                None
2188            }
2189            DebugAction::ActionLogShowDetail => {
2190                if let Some(DebugOverlay::ActionLog(ref log)) = self.freeze.overlay {
2191                    if let Some(detail) = log.selected_detail() {
2192                        self.detail_scroll.reset();
2193                        self.freeze.set_overlay(DebugOverlay::ActionDetail(detail));
2194                    }
2195                }
2196                None
2197            }
2198            DebugAction::ActionLogBackToList => {
2199                // Go back to action log from detail view
2200                if matches!(self.freeze.overlay, Some(DebugOverlay::ActionDetail(_))) {
2201                    self.show_action_log();
2202                }
2203                None
2204            }
2205            DebugAction::StateTreeShowDetail => {
2206                if let Some(ref selected_id) = self.state_tree_selected.clone() {
2207                    if let Some(detail) = self.build_state_entry_detail(selected_id) {
2208                        self.detail_scroll.reset();
2209                        self.freeze.set_overlay(DebugOverlay::StateDetail(detail));
2210                    }
2211                }
2212                None
2213            }
2214            DebugAction::StateTreeBackToTree => {
2215                if matches!(self.freeze.overlay, Some(DebugOverlay::StateDetail(_))) {
2216                    if let Some(ref table) = self.state_snapshot {
2217                        self.set_state_overlay(table.clone());
2218                    }
2219                }
2220                None
2221            }
2222            DebugAction::ComponentShowDetail => {
2223                if let Some(DebugOverlay::Components(ref overlay)) = self.freeze.overlay {
2224                    // Extract component index from tree selection "comp:N"
2225                    if let Some(idx) = self
2226                        .components_tree_selected
2227                        .as_ref()
2228                        .and_then(|id| id.strip_prefix("comp:"))
2229                        .and_then(|n| n.parse::<usize>().ok())
2230                    {
2231                        if let Some(comp) = overlay.components.get(idx) {
2232                            let detail =
2233                                super::table::ComponentDetailOverlay::from_snapshot(comp, idx);
2234                            self.detail_scroll.reset();
2235                            self.freeze
2236                                .set_overlay(DebugOverlay::ComponentDetail(detail));
2237                        }
2238                    }
2239                }
2240                None
2241            }
2242            DebugAction::ComponentBackToList => {
2243                if let Some(DebugOverlay::ComponentDetail(ref detail)) = self.freeze.overlay {
2244                    let selected_id = format!("comp:{}", detail.index);
2245                    self.show_components();
2246                    self.components_tree_selected = Some(selected_id);
2247                }
2248                None
2249            }
2250            DebugAction::ToggleMouseCapture => {
2251                self.freeze.toggle_mouse_capture();
2252                None
2253            }
2254            DebugAction::InspectCell { column, row } => {
2255                if let Some(ref snapshot) = self.freeze.snapshot {
2256                    let overlay = self.build_inspect_overlay(column, row, snapshot);
2257                    self.table_scroll.reset();
2258                    self.freeze.set_overlay(DebugOverlay::Inspect(overlay));
2259                }
2260                self.freeze.mouse_capture_enabled = false;
2261                None
2262            }
2263            DebugAction::CloseOverlay => {
2264                self.freeze.clear_overlay();
2265                None
2266            }
2267            DebugAction::RequestCapture => {
2268                self.freeze.request_capture();
2269                None
2270            }
2271        }
2272    }
2273
2274    fn split_for_banner(&self, area: Rect) -> (Rect, Rect) {
2275        let banner_height = area.height.min(1);
2276        match self.banner_position {
2277            BannerPosition::Bottom => {
2278                let app_area = Rect {
2279                    height: area.height.saturating_sub(banner_height),
2280                    ..area
2281                };
2282                let banner_area = Rect {
2283                    y: area.y.saturating_add(app_area.height),
2284                    height: banner_height,
2285                    ..area
2286                };
2287                (app_area, banner_area)
2288            }
2289            BannerPosition::Top => {
2290                let banner_area = Rect {
2291                    y: area.y,
2292                    height: banner_height,
2293                    ..area
2294                };
2295                let app_area = Rect {
2296                    y: area.y.saturating_add(banner_height),
2297                    height: area.height.saturating_sub(banner_height),
2298                    ..area
2299                };
2300                (app_area, banner_area)
2301            }
2302        }
2303    }
2304
2305    fn render_debug_overlay(&mut self, frame: &mut Frame, app_area: Rect, banner_area: Rect) {
2306        let overlay = self.freeze.overlay.clone();
2307
2308        // Only dim when there's an overlay open
2309        if let Some(ref overlay) = overlay {
2310            dim_buffer(frame.buffer_mut(), self.style.dim_factor);
2311
2312            match overlay {
2313                DebugOverlay::Inspect(table) => {
2314                    self.render_table_modal(frame, app_area, table);
2315                }
2316                DebugOverlay::State(table) => {
2317                    self.render_state_tree_modal(frame, app_area, table);
2318                }
2319                DebugOverlay::ActionLog(log) => {
2320                    self.render_action_log_modal(frame, app_area, log);
2321                }
2322                DebugOverlay::ActionDetail(detail) => {
2323                    self.render_action_detail_modal(frame, app_area, detail);
2324                }
2325                DebugOverlay::Components(components) => {
2326                    // Highlight the selected component's render area on the
2327                    // dimmed background, drawn before the modal so it sits
2328                    // underneath.
2329                    if let Some(idx) = self
2330                        .components_tree_selected
2331                        .as_ref()
2332                        .and_then(|id| id.strip_prefix("comp:"))
2333                        .and_then(|n| n.parse::<usize>().ok())
2334                    {
2335                        if let Some(comp) = components.components.get(idx) {
2336                            if let Some(area) = comp.last_area {
2337                                highlight_rect(frame.buffer_mut(), area, app_area);
2338                            }
2339                        }
2340                    }
2341                    self.render_components_modal(frame, app_area, &components.clone());
2342                }
2343                DebugOverlay::StateDetail(detail) => {
2344                    self.render_state_detail_modal(frame, app_area, &detail.clone());
2345                }
2346                DebugOverlay::ComponentDetail(detail) => {
2347                    self.render_component_detail_modal(frame, app_area, &detail.clone());
2348                }
2349            }
2350        }
2351
2352        // Render banner
2353        self.render_banner(frame, banner_area);
2354    }
2355
2356    fn render_banner(&self, frame: &mut Frame, banner_area: Rect) {
2357        if banner_area.height == 0 {
2358            return;
2359        }
2360
2361        use ratatui::text::Span;
2362
2363        let keys = &self.style.key_styles;
2364        let toggle_key_str = format_key(self.toggle_key);
2365        let label_style = self.style.label_style;
2366        let value_style = self.style.value_style;
2367
2368        let mut left_items: Vec<StatusBarItem<'static>> = Vec::new();
2369        left_items.push(StatusBarItem::span(Span::styled(
2370            " DEBUG ",
2371            self.style.title_style,
2372        )));
2373        left_items.push(StatusBarItem::text(" "));
2374
2375        let push_item =
2376            |items: &mut Vec<StatusBarItem<'static>>, key: &str, label: &str, key_style: Style| {
2377                items.push(StatusBarItem::span(Span::styled(
2378                    format!(" {key} "),
2379                    key_style,
2380                )));
2381                items.push(StatusBarItem::span(Span::styled(
2382                    format!(" {label} "),
2383                    label_style,
2384                )));
2385            };
2386
2387        push_item(&mut left_items, &toggle_key_str, "resume", keys.toggle);
2388        push_item(&mut left_items, "a", "actions", keys.actions);
2389        push_item(&mut left_items, "c", "components", keys.components);
2390        push_item(&mut left_items, "s", "state", keys.state);
2391        push_item(
2392            &mut left_items,
2393            "b",
2394            self.banner_position.label(),
2395            keys.actions,
2396        );
2397        push_item(&mut left_items, "y", "copy", keys.copy);
2398
2399        if self.freeze.mouse_capture_enabled {
2400            push_item(&mut left_items, "click", "inspect", keys.mouse);
2401        } else {
2402            push_item(&mut left_items, "i", "mouse", keys.mouse);
2403        }
2404
2405        let mut right_items: Vec<StatusBarItem<'static>> = Vec::new();
2406        if let Some(ref msg) = self.freeze.message {
2407            right_items.push(StatusBarItem::span(Span::styled(
2408                format!(" {msg} "),
2409                value_style,
2410            )));
2411        }
2412
2413        let style = StatusBarStyle {
2414            base: BaseStyle {
2415                border: None,
2416                padding: Padding::default(),
2417                bg: self.style.banner_bg.bg,
2418                fg: None,
2419            },
2420            text: label_style,
2421            hint_key: keys.toggle,
2422            hint_label: label_style,
2423            separator: label_style,
2424        };
2425
2426        let left = StatusBarSection::items(&left_items).with_separator("");
2427        let right = if right_items.is_empty() {
2428            StatusBarSection::empty()
2429        } else {
2430            StatusBarSection::items(&right_items).with_separator("")
2431        };
2432
2433        let mut status_bar = StatusBar::new();
2434        <StatusBar as tui_dispatch_core::Component<()>>::render(
2435            &mut status_bar,
2436            frame,
2437            banner_area,
2438            StatusBarProps {
2439                left,
2440                center: StatusBarSection::empty(),
2441                right,
2442                style,
2443                is_focused: false,
2444            },
2445        );
2446    }
2447
2448    fn render_state_tree_modal(
2449        &mut self,
2450        frame: &mut Frame,
2451        app_area: Rect,
2452        table: &DebugTableOverlay,
2453    ) {
2454        self.sync_state_tree_state();
2455
2456        let mut tree_view = std::mem::take(&mut self.state_tree_view);
2457        let filtered = &self.state_tree_filtered;
2458        let selected_id = self.state_tree_selected.as_ref();
2459        let expanded_ids = &self.state_tree_expanded;
2460        let style = self.state_tree_style();
2461        let palette = &self.style;
2462        let render_node = |ctx: TreeNodeRender<'_, String, String>| -> Line<'static> {
2463            render_state_tree_node(ctx, palette)
2464        };
2465        let search_footer =
2466            self.search_footer_center_items(&self.state_search_query, self.state_search_active);
2467        let hints = if self.state_search_active {
2468            SEARCH_INPUT_HINTS
2469        } else {
2470            STATE_TREE_HINTS
2471        };
2472
2473        self.render_overlay_container(
2474            frame,
2475            app_area,
2476            &table.title,
2477            Some(hints),
2478            search_footer,
2479            |frame, body_area| {
2480                let body_area = Rect {
2481                    x: body_area.x.saturating_add(1),
2482                    y: body_area.y,
2483                    width: body_area.width.saturating_sub(2),
2484                    height: body_area.height,
2485                };
2486                if body_area.width == 0 {
2487                    return;
2488                }
2489
2490                let props = Self::build_tree_props(
2491                    filtered,
2492                    selected_id,
2493                    expanded_ids,
2494                    style.clone(),
2495                    &render_node,
2496                );
2497
2498                <TreeView<String> as tui_dispatch_core::Component<StateTreeAction>>::render(
2499                    &mut tree_view,
2500                    frame,
2501                    body_area,
2502                    props,
2503                );
2504            },
2505        );
2506
2507        self.state_tree_view = tree_view;
2508    }
2509
2510    fn render_table_modal(&mut self, frame: &mut Frame, app_area: Rect, table: &DebugTableOverlay) {
2511        let scrollbar_style = self.component_scrollbar_style();
2512        let table_scroll_offset = self.table_scroll.offset;
2513        let style_snapshot = self.style.clone();
2514        let mut rows_page_size: usize = 0;
2515
2516        self.render_overlay_container(
2517            frame,
2518            app_area,
2519            &table.title,
2520            Some(INSPECT_HINTS),
2521            None,
2522            |frame, content_area| {
2523                let mut table_area = content_area;
2524                if let Some(ref preview) = table.cell_preview {
2525                    if table_area.height > 1 {
2526                        let preview_area = Rect {
2527                            height: 1,
2528                            ..table_area
2529                        };
2530                        let preview_widget =
2531                            CellPreviewWidget::from_style(preview, &style_snapshot);
2532                        frame.render_widget(preview_widget, preview_area);
2533
2534                        table_area = Rect {
2535                            y: table_area.y.saturating_add(1),
2536                            height: table_area.height.saturating_sub(1),
2537                            ..table_area
2538                        };
2539                    }
2540                }
2541
2542                if table_area.height == 0 || table_area.width == 0 {
2543                    return;
2544                }
2545
2546                use ratatui::text::{Line, Span};
2547                use ratatui::widgets::Paragraph;
2548
2549                let header_area = Rect {
2550                    height: 1,
2551                    ..table_area
2552                };
2553                let rows_area = Rect {
2554                    y: table_area.y.saturating_add(1),
2555                    height: table_area.height.saturating_sub(1),
2556                    ..table_area
2557                };
2558                rows_page_size = rows_area.height as usize;
2559
2560                let style = DebugTableStyle::from_style(&style_snapshot);
2561                let show_scrollbar = rows_area.height > 0
2562                    && table.rows.len() > rows_area.height as usize
2563                    && table_area.width > 1;
2564                let text_width = if show_scrollbar {
2565                    table_area.width.saturating_sub(1)
2566                } else {
2567                    table_area.width
2568                } as usize;
2569
2570                if text_width == 0 {
2571                    return;
2572                }
2573
2574                let max_key_len = table
2575                    .rows
2576                    .iter()
2577                    .filter_map(|row| match row {
2578                        super::table::DebugTableRow::Entry { key, .. } => Some(key.chars().count()),
2579                        super::table::DebugTableRow::Section(_) => None,
2580                    })
2581                    .max()
2582                    .unwrap_or(0);
2583                let max_label = text_width.saturating_sub(8).max(10);
2584                let label_width = (max_key_len + 2).clamp(12, 30).min(max_label);
2585                let value_width = text_width.saturating_sub(label_width + 2).max(1);
2586
2587                let header_line = Line::from(vec![
2588                    Span::styled(pad_text("Field", label_width), style.header),
2589                    Span::styled("  ", style.header),
2590                    Span::styled(pad_text("Value", value_width), style.header),
2591                ]);
2592                frame.render_widget(Paragraph::new(header_line), header_area);
2593
2594                if rows_area.height == 0 {
2595                    return;
2596                }
2597
2598                let syntax_style = DebugSyntaxStyle::from_style(&style_snapshot, style.value);
2599                let mut rows = Vec::new();
2600                let mut entry_index = 0usize;
2601                for row in table.rows.iter() {
2602                    match row {
2603                        super::table::DebugTableRow::Section(title) => {
2604                            entry_index = 0;
2605                            let mut text = format!(" {title} ");
2606                            text = truncate_with_ellipsis(&text, text_width);
2607                            text = pad_text(&text, text_width);
2608                            rows.push(Line::from(vec![Span::styled(text, style.section)]));
2609                        }
2610                        super::table::DebugTableRow::Entry { key, value } => {
2611                            let row_style = if entry_index % 2 == 0 {
2612                                style.row_styles.0
2613                            } else {
2614                                style.row_styles.1
2615                            };
2616                            entry_index = entry_index.saturating_add(1);
2617                            let key_text = pad_text(key, label_width);
2618                            let mut value_text = truncate_with_ellipsis(value, value_width);
2619                            value_text = pad_text(&value_text, value_width);
2620
2621                            let mut value_spans: Vec<Span<'static>> =
2622                                debug_spans(&value_text, &syntax_style)
2623                                    .into_iter()
2624                                    .map(|span| span.patch_style(row_style))
2625                                    .collect();
2626
2627                            let mut spans = vec![
2628                                Span::styled(key_text, style.key).patch_style(row_style),
2629                                Span::styled("  ", row_style),
2630                            ];
2631                            spans.append(&mut value_spans);
2632
2633                            rows.push(Line::from(spans));
2634                        }
2635                    }
2636                }
2637
2638                render_scroll_view(
2639                    frame,
2640                    rows_area,
2641                    &rows,
2642                    table_scroll_offset,
2643                    None,
2644                    &scrollbar_style,
2645                );
2646            },
2647        );
2648
2649        self.table_scroll.page_size = rows_page_size.max(1);
2650    }
2651
2652    fn render_action_log_modal(&self, frame: &mut Frame, app_area: Rect, log: &ActionLogOverlay) {
2653        let entry_count = log.entries.len();
2654        let filter_active = log.has_search_query();
2655        let filtered_count = if filter_active {
2656            log.search_match_count()
2657        } else {
2658            entry_count
2659        };
2660        let title = if log.has_search_query() {
2661            format!("{} ({} / {} shown)", log.title, filtered_count, entry_count)
2662        } else if entry_count > 0 {
2663            format!("{} ({} entries)", log.title, entry_count)
2664        } else {
2665            format!("{} (empty)", log.title)
2666        };
2667
2668        let action_log_hints = if log.search_input_active {
2669            ACTION_LOG_SEARCH_INPUT_HINTS
2670        } else {
2671            ACTION_LOG_HINTS
2672        };
2673
2674        let log_style = ActionLogStyle::from_style(&self.style);
2675        let log_syntax = DebugSyntaxStyle::from_style(&self.style, log_style.params);
2676        let text_secondary_fg = self.style.text_secondary;
2677
2678        self.render_overlay_container(
2679            frame,
2680            app_area,
2681            &title,
2682            Some(action_log_hints),
2683            self.search_footer_center_items(&log.search_query, log.search_input_active),
2684            |frame, log_area| {
2685                use ratatui::text::{Line, Span};
2686                use ratatui::widgets::Paragraph;
2687
2688                let style = &log_style;
2689                let spacing = 1usize;
2690                let seq_width = 5usize;
2691                let name_width = 20usize;
2692                let elapsed_width = 8usize;
2693
2694                let header_area = Rect {
2695                    height: 1,
2696                    ..log_area
2697                };
2698                let body_area = Rect {
2699                    y: log_area.y.saturating_add(1),
2700                    height: log_area.height.saturating_sub(1),
2701                    ..log_area
2702                };
2703
2704                let selected_visible_idx = if filtered_count == 0 {
2705                    0
2706                } else if filter_active {
2707                    let selected_match_position = log
2708                        .search_matches
2709                        .iter()
2710                        .position(|&idx| idx == log.selected);
2711                    // Defensive fallback in case selection/matches drift out of sync.
2712                    selected_match_position
2713                        .unwrap_or(log.search_match_index.min(filtered_count.saturating_sub(1)))
2714                } else {
2715                    log.selected.min(filtered_count.saturating_sub(1))
2716                };
2717
2718                let show_scrollbar = body_area.height > 0
2719                    && filtered_count > body_area.height as usize
2720                    && log_area.width > 1;
2721                let text_width = if show_scrollbar {
2722                    log_area.width.saturating_sub(1)
2723                } else {
2724                    log_area.width
2725                } as usize;
2726                let params_width = text_width
2727                    .saturating_sub(seq_width + name_width + elapsed_width + spacing * 3)
2728                    .max(1);
2729
2730                let header_line = Line::from(vec![
2731                    Span::styled(pad_text("#", seq_width), style.header),
2732                    Span::styled(" ", style.header),
2733                    Span::styled(pad_text("Action", name_width), style.header),
2734                    Span::styled(" ", style.header),
2735                    Span::styled(pad_text("Params", params_width), style.header),
2736                    Span::styled(" ", style.header),
2737                    Span::styled(pad_text("Elapsed", elapsed_width), style.header),
2738                ]);
2739                frame.render_widget(Paragraph::new(header_line), header_area);
2740
2741                if body_area.height == 0 {
2742                    return;
2743                }
2744
2745                let syntax_style = log_syntax.clone();
2746                let rows: Vec<Line> = if filtered_count == 0 {
2747                    vec![Line::from(vec![Span::styled(
2748                        " (no matching actions) ",
2749                        Style::default().fg(text_secondary_fg),
2750                    )])]
2751                } else {
2752                    (0..filtered_count)
2753                        .map(|visible_idx| {
2754                            let entry_idx = if filter_active {
2755                                log.search_matches[visible_idx]
2756                            } else {
2757                                visible_idx
2758                            };
2759                            let entry = &log.entries[entry_idx];
2760                            let is_selected = visible_idx == selected_visible_idx;
2761                            let row_style = if is_selected {
2762                                style.selected
2763                            } else if visible_idx % 2 == 0 {
2764                                style.row_styles.0
2765                            } else {
2766                                style.row_styles.1
2767                            };
2768
2769                            let seq_text = pad_text(&entry.sequence.to_string(), seq_width);
2770                            let name_text = pad_text(&entry.name, name_width);
2771                            let elapsed_text = pad_text(&entry.elapsed, elapsed_width);
2772
2773                            let params_compact = entry.params.replace('\n', " ");
2774                            let params_compact = params_compact
2775                                .split_whitespace()
2776                                .collect::<Vec<_>>()
2777                                .join(" ");
2778                            let params_trimmed =
2779                                truncate_with_ellipsis(&params_compact, params_width);
2780                            let params_text = pad_text(&params_trimmed, params_width);
2781                            let mut params_spans: Vec<Span<'static>> =
2782                                debug_spans(&params_text, &syntax_style)
2783                                    .into_iter()
2784                                    .map(|span| span.patch_style(row_style))
2785                                    .collect();
2786
2787                            let mut spans = vec![
2788                                Span::styled(seq_text, style.sequence).patch_style(row_style),
2789                                Span::styled(" ", row_style),
2790                                Span::styled(name_text, style.name).patch_style(row_style),
2791                                Span::styled(" ", row_style),
2792                            ];
2793                            spans.append(&mut params_spans);
2794                            spans.push(Span::styled(" ", row_style));
2795                            spans.push(
2796                                Span::styled(elapsed_text, style.elapsed).patch_style(row_style),
2797                            );
2798
2799                            Line::from(spans)
2800                        })
2801                        .collect()
2802                };
2803
2804                let visible_rows = body_area.height as usize;
2805                let scroll_offset = if visible_rows == 0 || selected_visible_idx < visible_rows {
2806                    0
2807                } else {
2808                    selected_visible_idx - visible_rows + 1
2809                };
2810                let scrollbar = self.component_scrollbar_style();
2811                render_scroll_view(frame, body_area, &rows, scroll_offset, None, &scrollbar);
2812            },
2813        );
2814    }
2815
2816    fn search_footer_center_items(
2817        &self,
2818        query: &str,
2819        input_active: bool,
2820    ) -> Option<Vec<StatusBarItem<'static>>> {
2821        use ratatui::text::Span;
2822
2823        if query.is_empty() && !input_active {
2824            return None;
2825        }
2826
2827        let key_style = Style::default()
2828            .fg(self.style.bg_deep)
2829            .bg(self.style.text_secondary)
2830            .add_modifier(Modifier::BOLD);
2831        let value_style = Style::default().fg(self.style.text_primary);
2832
2833        let filter_value = if input_active {
2834            format!("/{}_", query)
2835        } else {
2836            format!("/{}", query)
2837        };
2838
2839        Some(vec![
2840            StatusBarItem::span(Span::styled(" filter ", key_style)),
2841            StatusBarItem::span(Span::styled(format!(" {} ", filter_value), value_style)),
2842        ])
2843    }
2844
2845    fn render_action_detail_modal(
2846        &mut self,
2847        frame: &mut Frame,
2848        app_area: Rect,
2849        detail: &super::table::ActionDetailOverlay,
2850    ) {
2851        let title = format!("Action #{} - {}", detail.sequence, detail.name);
2852        let param_lines = self.detail_params_lines(detail);
2853        let scrollbar_style = self.component_scrollbar_style();
2854        let detail_scroll_offset = self.detail_scroll.offset;
2855        let detail_page_size = self.detail_scroll.page_size;
2856        let detail_label_fg = self.style.text_secondary;
2857        let detail_value_fg = self.style.text_primary;
2858        let detail_params_bg = self.style.overlay_bg_dark;
2859        let mut body_page_size: usize = 0;
2860
2861        self.render_overlay_container(
2862            frame,
2863            app_area,
2864            &title,
2865            Some(ACTION_DETAIL_HINTS),
2866            None,
2867            |frame, detail_area| {
2868                use ratatui::text::{Line, Span};
2869                use ratatui::widgets::Paragraph;
2870
2871                let label_style = Style::default().fg(detail_label_fg);
2872                let value_style = Style::default().fg(detail_value_fg);
2873
2874                let header_lines = vec![
2875                    Line::from(vec![
2876                        Span::styled("Name: ", label_style),
2877                        Span::styled(&detail.name, value_style),
2878                    ]),
2879                    Line::from(vec![
2880                        Span::styled("Sequence: ", label_style),
2881                        Span::styled(detail.sequence.to_string(), value_style),
2882                    ]),
2883                    Line::from(vec![
2884                        Span::styled("Elapsed: ", label_style),
2885                        Span::styled(&detail.elapsed, value_style),
2886                    ]),
2887                    Line::from(""),
2888                    Line::from(Span::styled("Parameters:", label_style)),
2889                ];
2890
2891                let mut param_lines = param_lines.clone();
2892                if param_lines.is_empty() {
2893                    param_lines.push(Line::from(Span::styled("  (none)", value_style)));
2894                }
2895                let param_lines_len = param_lines.len();
2896
2897                let footer_lines = vec![Line::from("")];
2898
2899                let header_height = header_lines.len() as u16;
2900                let footer_height = footer_lines.len() as u16;
2901
2902                let header_area_height = header_height.min(detail_area.height);
2903                let header_area = Rect {
2904                    height: header_area_height,
2905                    ..detail_area
2906                };
2907                if header_area.height > 0 {
2908                    let paragraph = Paragraph::new(header_lines);
2909                    frame.render_widget(paragraph, header_area);
2910                }
2911
2912                let footer_area_height =
2913                    footer_height.min(detail_area.height.saturating_sub(header_area_height));
2914                let footer_area = Rect {
2915                    x: detail_area.x,
2916                    y: detail_area
2917                        .y
2918                        .saturating_add(detail_area.height.saturating_sub(footer_area_height)),
2919                    width: detail_area.width,
2920                    height: footer_area_height,
2921                };
2922                if footer_area.height > 0 {
2923                    let paragraph = Paragraph::new(footer_lines);
2924                    frame.render_widget(paragraph, footer_area);
2925                }
2926
2927                let params_area = Rect {
2928                    x: detail_area.x,
2929                    y: detail_area.y.saturating_add(header_area_height),
2930                    width: detail_area.width,
2931                    height: detail_area
2932                        .height
2933                        .saturating_sub(header_area_height + footer_area_height),
2934                };
2935                body_page_size = params_area.height as usize;
2936
2937                let max_offset = param_lines_len.saturating_sub(detail_page_size.max(1));
2938                let current_scroll_offset = detail_scroll_offset.min(max_offset);
2939
2940                if params_area.height > 0 {
2941                    render_scroll_view(
2942                        frame,
2943                        params_area,
2944                        &param_lines,
2945                        current_scroll_offset,
2946                        Some(detail_params_bg),
2947                        &scrollbar_style,
2948                    );
2949                }
2950            },
2951        );
2952
2953        self.detail_scroll.page_size = body_page_size.max(1);
2954    }
2955
2956    fn sync_components_tree_state(&mut self) {
2957        let nodes = &self.components_tree_nodes;
2958        self.components_tree_expanded
2959            .retain(|id| Self::tree_contains_id(nodes, id));
2960
2961        let filtered = &self.components_tree_filtered;
2962        let selected_valid = self
2963            .components_tree_selected
2964            .as_deref()
2965            .map(|id| Self::tree_contains_id(filtered, id))
2966            .unwrap_or(false);
2967
2968        if !selected_valid {
2969            self.components_tree_selected = filtered.first().map(|node| node.id.clone());
2970        }
2971    }
2972
2973    fn render_components_modal(
2974        &mut self,
2975        frame: &mut Frame,
2976        app_area: Rect,
2977        overlay: &super::table::ComponentsOverlay,
2978    ) {
2979        let count = overlay.components.len();
2980        let title = if count > 0 {
2981            format!("{} ({} mounted)", overlay.title, count)
2982        } else {
2983            format!("{} (none)", overlay.title)
2984        };
2985
2986        self.sync_components_tree_state();
2987
2988        let mut tree_view = std::mem::take(&mut self.components_tree_view);
2989        let filtered = &self.components_tree_filtered;
2990        let selected_id = self.components_tree_selected.as_ref();
2991        let expanded_ids = &self.components_tree_expanded;
2992        let style = self.state_tree_style();
2993        let palette = &self.style;
2994        let render_node = |ctx: TreeNodeRender<'_, String, String>| -> Line<'static> {
2995            render_state_tree_node(ctx, palette)
2996        };
2997        let search_footer = self.search_footer_center_items(
2998            &self.components_search_query,
2999            self.components_search_active,
3000        );
3001        let hints = if self.components_search_active {
3002            SEARCH_INPUT_HINTS
3003        } else {
3004            COMPONENTS_HINTS
3005        };
3006
3007        self.render_overlay_container(
3008            frame,
3009            app_area,
3010            &title,
3011            Some(hints),
3012            search_footer,
3013            |frame, body_area| {
3014                let body_area = Rect {
3015                    x: body_area.x.saturating_add(1),
3016                    y: body_area.y,
3017                    width: body_area.width.saturating_sub(2),
3018                    height: body_area.height,
3019                };
3020                if body_area.width == 0 {
3021                    return;
3022                }
3023
3024                let props = Self::build_tree_props(
3025                    filtered,
3026                    selected_id,
3027                    expanded_ids,
3028                    style.clone(),
3029                    &render_node,
3030                );
3031
3032                <TreeView<String> as tui_dispatch_core::Component<StateTreeAction>>::render(
3033                    &mut tree_view,
3034                    frame,
3035                    body_area,
3036                    props,
3037                );
3038            },
3039        );
3040
3041        self.components_tree_view = tree_view;
3042    }
3043
3044    fn detail_params_lines(
3045        &self,
3046        detail: &super::table::ActionDetailOverlay,
3047    ) -> Vec<ratatui::text::Line<'static>> {
3048        use ratatui::text::{Line, Span};
3049
3050        if detail.params.is_empty() {
3051            return Vec::new();
3052        }
3053
3054        let value_style = Style::default().fg(self.style.text_primary);
3055        let syntax_style = DebugSyntaxStyle::from_style(&self.style, value_style);
3056
3057        detail
3058            .params
3059            .lines()
3060            .map(|line| {
3061                let mut spans = vec![Span::styled("  ", value_style)];
3062                spans.extend(debug_spans(line, &syntax_style));
3063                Line::from(spans)
3064            })
3065            .collect()
3066    }
3067
3068    fn state_detail_lines(
3069        &self,
3070        detail: &super::table::StateEntryDetail,
3071    ) -> Vec<ratatui::text::Line<'static>> {
3072        use ratatui::text::{Line, Span};
3073
3074        let label_style = Style::default().fg(self.style.text_secondary);
3075        let value_style = Style::default().fg(self.style.text_primary);
3076        let syntax_style = DebugSyntaxStyle::from_style(&self.style, value_style);
3077
3078        let mut lines = vec![
3079            Line::from(vec![
3080                Span::styled("Section: ", label_style),
3081                Span::styled(detail.section.clone(), value_style),
3082            ]),
3083            Line::from(vec![
3084                Span::styled("Key: ", label_style),
3085                Span::styled(detail.key.clone(), value_style),
3086            ]),
3087            Line::default(),
3088            Line::from(Span::styled("Value:", label_style)),
3089        ];
3090
3091        let pretty_value = super::format::pretty_reformat(&detail.value);
3092        for line in pretty_value.lines() {
3093            let mut spans = vec![Span::styled("  ", value_style)];
3094            spans.extend(debug_spans(line, &syntax_style));
3095            lines.push(Line::from(spans));
3096        }
3097
3098        if detail.value.is_empty() {
3099            lines.push(Line::from(Span::styled("  (empty)", label_style)));
3100        }
3101
3102        lines
3103    }
3104
3105    fn render_state_detail_modal(
3106        &mut self,
3107        frame: &mut Frame,
3108        app_area: Rect,
3109        detail: &super::table::StateEntryDetail,
3110    ) {
3111        let title = format!("{} - {}", detail.section, detail.key);
3112        let content_lines = self.state_detail_lines(detail);
3113        let scrollbar_style = self.component_scrollbar_style();
3114        let detail_scroll_offset = self.detail_scroll.offset;
3115        let detail_bg = self.style.overlay_bg_dark;
3116        let mut body_page_size: usize = 0;
3117
3118        self.render_overlay_container(
3119            frame,
3120            app_area,
3121            &title,
3122            Some(STATE_DETAIL_HINTS),
3123            None,
3124            |frame, body_area| {
3125                body_page_size = body_area.height as usize;
3126                if body_area.height > 0 {
3127                    render_scroll_view(
3128                        frame,
3129                        body_area,
3130                        &content_lines,
3131                        detail_scroll_offset,
3132                        Some(detail_bg),
3133                        &scrollbar_style,
3134                    );
3135                }
3136            },
3137        );
3138
3139        self.detail_scroll.page_size = body_page_size.max(1);
3140    }
3141
3142    fn component_detail_lines(
3143        &self,
3144        detail: &super::table::ComponentDetailOverlay,
3145    ) -> Vec<ratatui::text::Line<'static>> {
3146        use ratatui::text::{Line, Span};
3147
3148        let label_style = Style::default().fg(self.style.text_secondary);
3149        let value_style = Style::default().fg(self.style.text_primary);
3150        let type_style = Style::default().fg(self.style.neon_cyan);
3151
3152        let mut lines = vec![
3153            Line::from(vec![
3154                Span::styled("Type: ", label_style),
3155                Span::styled(detail.type_name.clone(), type_style),
3156            ]),
3157            Line::from(vec![
3158                Span::styled("Full path: ", label_style),
3159                Span::styled(detail.type_name_full.clone(), value_style),
3160            ]),
3161            Line::from(vec![
3162                Span::styled("ID: ", label_style),
3163                Span::styled(
3164                    detail.bound_id.as_deref().unwrap_or("(none)").to_string(),
3165                    value_style,
3166                ),
3167            ]),
3168            Line::from(vec![
3169                Span::styled("Area: ", label_style),
3170                Span::styled(
3171                    detail
3172                        .last_area
3173                        .map(|a| format!("{}x{} at ({},{})", a.width, a.height, a.x, a.y))
3174                        .unwrap_or_else(|| "(not rendered)".into()),
3175                    value_style,
3176                ),
3177            ]),
3178        ];
3179
3180        if !detail.debug_entries.is_empty() {
3181            lines.push(Line::default());
3182            lines.push(Line::from(Span::styled("Debug State:", label_style)));
3183
3184            let syntax_style = DebugSyntaxStyle::from_style(&self.style, value_style);
3185            let max_key = detail
3186                .debug_entries
3187                .iter()
3188                .map(|(k, _)| k.len())
3189                .max()
3190                .unwrap_or(0);
3191
3192            for (key, value) in &detail.debug_entries {
3193                let pretty = super::format::pretty_reformat(value);
3194                let pretty_lines: Vec<&str> = pretty.lines().collect();
3195                let padded_key = format!("  {key:>width$}: ", width = max_key);
3196                if pretty_lines.len() <= 1 {
3197                    let mut spans = vec![Span::styled(padded_key, label_style)];
3198                    spans.extend(debug_spans(
3199                        pretty_lines.first().unwrap_or(&""),
3200                        &syntax_style,
3201                    ));
3202                    lines.push(Line::from(spans));
3203                } else {
3204                    // Multi-line: key on first line, value lines indented below
3205                    let indent = " ".repeat(max_key + 5); // "  " + key + ": "
3206                    let mut first_spans = vec![Span::styled(padded_key, label_style)];
3207                    first_spans.extend(debug_spans(pretty_lines[0], &syntax_style));
3208                    lines.push(Line::from(first_spans));
3209                    for vline in &pretty_lines[1..] {
3210                        let mut spans = vec![Span::styled(indent.clone(), value_style)];
3211                        spans.extend(debug_spans(vline, &syntax_style));
3212                        lines.push(Line::from(spans));
3213                    }
3214                }
3215            }
3216        }
3217
3218        lines
3219    }
3220
3221    fn render_component_detail_modal(
3222        &mut self,
3223        frame: &mut Frame,
3224        app_area: Rect,
3225        detail: &super::table::ComponentDetailOverlay,
3226    ) {
3227        let title = format!("Component - {}", detail.type_name);
3228        let content_lines = self.component_detail_lines(detail);
3229        let scrollbar_style = self.component_scrollbar_style();
3230        let detail_scroll_offset = self.detail_scroll.offset;
3231        let detail_bg = self.style.overlay_bg_dark;
3232        let mut body_page_size: usize = 0;
3233
3234        self.render_overlay_container(
3235            frame,
3236            app_area,
3237            &title,
3238            Some(COMPONENT_DETAIL_HINTS),
3239            None,
3240            |frame, body_area| {
3241                body_page_size = body_area.height as usize;
3242                if body_area.height > 0 {
3243                    render_scroll_view(
3244                        frame,
3245                        body_area,
3246                        &content_lines,
3247                        detail_scroll_offset,
3248                        Some(detail_bg),
3249                        &scrollbar_style,
3250                    );
3251                }
3252            },
3253        );
3254
3255        self.detail_scroll.page_size = body_page_size.max(1);
3256    }
3257
3258    fn build_inspect_overlay(&self, column: u16, row: u16, snapshot: &Buffer) -> DebugTableOverlay {
3259        let mut builder = DebugTableBuilder::new();
3260
3261        builder.push_section("Position");
3262        builder.push_entry("column", column.to_string());
3263        builder.push_entry("row", row.to_string());
3264
3265        if let Some(preview) = inspect_cell(snapshot, column, row) {
3266            builder.set_cell_preview(preview);
3267        }
3268
3269        builder.finish(format!("Inspect ({column}, {row})"))
3270    }
3271}
3272
3273/// Filter tree nodes by a case-insensitive substring query.
3274///
3275/// If a parent's label matches, all its children are kept.
3276/// If only some children match, the parent is kept with just those children.
3277fn filter_tree_nodes(
3278    nodes: &[TreeNode<String, String>],
3279    query: &str,
3280) -> Vec<TreeNode<String, String>> {
3281    if query.is_empty() {
3282        return nodes.to_vec();
3283    }
3284    let q = query.to_lowercase();
3285    nodes
3286        .iter()
3287        .filter_map(|node| {
3288            if node.value.to_lowercase().contains(&q) {
3289                return Some(node.clone());
3290            }
3291            let matching: Vec<_> = node
3292                .children
3293                .iter()
3294                .filter(|c| c.value.to_lowercase().contains(&q))
3295                .cloned()
3296                .collect();
3297            if matching.is_empty() {
3298                None
3299            } else {
3300                Some(TreeNode::with_children(
3301                    node.id.clone(),
3302                    node.value.clone(),
3303                    matching,
3304                ))
3305            }
3306        })
3307        .collect()
3308}
3309
3310/// Render pre-built `Line`s inside a `ScrollView` with a scrollbar.
3311///
3312/// Free function to avoid `&mut self` borrow conflicts inside closures.
3313fn render_scroll_view(
3314    frame: &mut Frame,
3315    area: Rect,
3316    lines: &[Line<'_>],
3317    scroll_offset: usize,
3318    bg: Option<ratatui::style::Color>,
3319    scrollbar_style: &ComponentScrollbarStyle,
3320) {
3321    let scroll_style = ScrollViewStyle {
3322        base: BaseStyle {
3323            border: None,
3324            padding: Padding::default(),
3325            bg,
3326            fg: None,
3327        },
3328        scrollbar: scrollbar_style.clone(),
3329    };
3330    let scroller = LinesScroller::new(lines);
3331    let mut scroll_view = ScrollView::new();
3332    <ScrollView as tui_dispatch_core::Component<()>>::render(
3333        &mut scroll_view,
3334        frame,
3335        area,
3336        ScrollViewProps {
3337            content_height: scroller.content_height(),
3338            scroll_offset,
3339            is_focused: true,
3340            style: scroll_style,
3341            behavior: ScrollViewBehavior::default(),
3342            on_scroll: Rc::new(|_| ()),
3343            render_content: &mut scroller.renderer(),
3344        },
3345    );
3346}
3347
3348#[derive(Debug, Serialize)]
3349struct DebugStateSnapshot {
3350    title: String,
3351    sections: Vec<DebugStateSnapshotSection>,
3352}
3353
3354#[derive(Debug, Serialize)]
3355struct DebugStateSnapshotSection {
3356    title: String,
3357    entries: Vec<DebugStateSnapshotEntry>,
3358}
3359
3360#[derive(Debug, Serialize)]
3361struct DebugStateSnapshotEntry {
3362    key: String,
3363    value: String,
3364}
3365
3366impl DebugStateSnapshot {
3367    fn from_state<S: DebugState>(state: &S, title: &str) -> Self {
3368        let sections = state
3369            .debug_sections()
3370            .into_iter()
3371            .map(|section| DebugStateSnapshotSection {
3372                title: section.title,
3373                entries: section
3374                    .entries
3375                    .into_iter()
3376                    .map(|entry| DebugStateSnapshotEntry {
3377                        key: entry.key,
3378                        value: entry.value,
3379                    })
3380                    .collect(),
3381            })
3382            .collect();
3383
3384        Self {
3385            title: title.to_string(),
3386            sections,
3387        }
3388    }
3389}
3390
3391/// Draw a highlighted border around `target` on the dimmed buffer.
3392///
3393/// Uses Unicode box-drawing characters for the border and clips to `clip`
3394/// so we never write outside the visible app area.
3395fn highlight_rect(buf: &mut Buffer, target: Rect, clip: Rect) {
3396    use ratatui::style::Color;
3397
3398    // Intersect target with the clip region
3399    let x0 = target.x.max(clip.x);
3400    let y0 = target.y.max(clip.y);
3401    let x1 = (target.x.saturating_add(target.width)).min(clip.x.saturating_add(clip.width));
3402    let y1 = (target.y.saturating_add(target.height)).min(clip.y.saturating_add(clip.height));
3403
3404    if x1 <= x0 || y1 <= y0 {
3405        return;
3406    }
3407
3408    let border_fg = Color::Rgb(0, 255, 255); // neon cyan
3409    let border_style = Style::default().fg(border_fg);
3410
3411    let left = x0;
3412    let right = x1.saturating_sub(1);
3413    let top = y0;
3414    let bottom = y1.saturating_sub(1);
3415
3416    // Corners
3417    if left <= right && top <= bottom {
3418        let tl = &mut buf[(left, top)];
3419        tl.set_symbol("┌");
3420        tl.set_style(border_style);
3421
3422        if right > left {
3423            let tr = &mut buf[(right, top)];
3424            tr.set_symbol("┐");
3425            tr.set_style(border_style);
3426        }
3427
3428        if bottom > top {
3429            let bl = &mut buf[(left, bottom)];
3430            bl.set_symbol("└");
3431            bl.set_style(border_style);
3432
3433            if right > left {
3434                let br = &mut buf[(right, bottom)];
3435                br.set_symbol("┘");
3436                br.set_style(border_style);
3437            }
3438        }
3439    }
3440
3441    // Top and bottom edges
3442    for x in (left + 1)..right {
3443        let cell = &mut buf[(x, top)];
3444        cell.set_symbol("─");
3445        cell.set_style(border_style);
3446
3447        if bottom > top {
3448            let cell = &mut buf[(x, bottom)];
3449            cell.set_symbol("─");
3450            cell.set_style(border_style);
3451        }
3452    }
3453
3454    // Left and right edges
3455    for y in (top + 1)..bottom {
3456        let cell = &mut buf[(left, y)];
3457        cell.set_symbol("│");
3458        cell.set_style(border_style);
3459
3460        if right > left {
3461            let cell = &mut buf[(right, y)];
3462            cell.set_symbol("│");
3463            cell.set_style(border_style);
3464        }
3465    }
3466}
3467
3468fn pad_text(value: &str, width: usize) -> String {
3469    if width == 0 {
3470        return String::new();
3471    }
3472    let mut text: String = value.chars().take(width).collect();
3473    let len = text.chars().count();
3474    if len < width {
3475        text.push_str(&" ".repeat(width - len));
3476    }
3477    text
3478}
3479
3480fn truncate_with_ellipsis(value: &str, width: usize) -> String {
3481    if width == 0 {
3482        return String::new();
3483    }
3484    let count = value.chars().count();
3485    if count <= width {
3486        return value.to_string();
3487    }
3488    if width <= 3 {
3489        return value.chars().take(width).collect();
3490    }
3491    let mut text: String = value.chars().take(width - 3).collect();
3492    text.push_str("...");
3493    text
3494}
3495
3496/// Format a KeyCode for display in the banner.
3497fn format_key(key: KeyCode) -> String {
3498    match key {
3499        KeyCode::F(n) => format!("F{}", n),
3500        KeyCode::Char(c) => c.to_string(),
3501        KeyCode::Esc => "Esc".to_string(),
3502        KeyCode::Enter => "Enter".to_string(),
3503        KeyCode::Tab => "Tab".to_string(),
3504        KeyCode::Backspace => "Bksp".to_string(),
3505        KeyCode::Delete => "Del".to_string(),
3506        KeyCode::Up => "↑".to_string(),
3507        KeyCode::Down => "↓".to_string(),
3508        KeyCode::Left => "←".to_string(),
3509        KeyCode::Right => "→".to_string(),
3510        _ => format!("{:?}", key),
3511    }
3512}
3513
3514#[cfg(test)]
3515mod tests {
3516    use super::*;
3517
3518    #[derive(Debug, Clone)]
3519    enum TestAction {
3520        Foo,
3521        Bar,
3522    }
3523
3524    impl tui_dispatch_core::Action for TestAction {
3525        fn name(&self) -> &'static str {
3526            match self {
3527                TestAction::Foo => "Foo",
3528                TestAction::Bar => "Bar",
3529            }
3530        }
3531    }
3532
3533    impl tui_dispatch_core::ActionParams for TestAction {
3534        fn params(&self) -> String {
3535            String::new()
3536        }
3537    }
3538
3539    #[test]
3540    fn test_debug_layer_creation() {
3541        let layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
3542        assert!(!layer.is_enabled());
3543        assert!(layer.freeze().snapshot.is_none());
3544    }
3545
3546    #[test]
3547    fn test_toggle() {
3548        let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
3549
3550        // Enable
3551        let effect = layer.toggle();
3552        assert!(effect.is_none());
3553        assert!(layer.is_enabled());
3554
3555        // Disable
3556        let effect = layer.toggle();
3557        assert!(effect.is_none()); // No queued actions
3558        assert!(!layer.is_enabled());
3559    }
3560
3561    #[test]
3562    fn test_set_enabled() {
3563        let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
3564
3565        layer.set_enabled(true);
3566        assert!(layer.is_enabled());
3567
3568        layer.set_enabled(false);
3569        assert!(!layer.is_enabled());
3570    }
3571
3572    #[test]
3573    fn test_simple_constructor() {
3574        let layer: DebugLayer<TestAction> = DebugLayer::simple();
3575        assert!(!layer.is_enabled());
3576    }
3577
3578    #[test]
3579    fn test_queued_actions_returned_on_disable() {
3580        let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
3581
3582        layer.toggle(); // Enable
3583        layer.queue_action(TestAction::Foo);
3584        layer.queue_action(TestAction::Bar);
3585
3586        let effect = layer.toggle(); // Disable
3587
3588        match effect {
3589            Some(DebugSideEffect::ProcessQueuedActions(actions)) => {
3590                assert_eq!(actions.len(), 2);
3591            }
3592            _ => panic!("Expected ProcessQueuedActions"),
3593        }
3594    }
3595
3596    #[test]
3597    fn test_split_area() {
3598        let layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
3599
3600        // Disabled: full area returned
3601        let area = Rect::new(0, 0, 80, 24);
3602        let (app, banner) = layer.split_area(area);
3603        assert_eq!(app, area);
3604        assert_eq!(banner, Rect::ZERO);
3605    }
3606
3607    #[test]
3608    fn test_split_area_enabled() {
3609        let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
3610        layer.toggle();
3611
3612        let area = Rect::new(0, 0, 80, 24);
3613        let (app, banner) = layer.split_area(area);
3614
3615        assert_eq!(app.height, 23);
3616        assert_eq!(banner.height, 1);
3617        assert_eq!(banner.y, 23);
3618    }
3619
3620    #[test]
3621    fn test_split_area_enabled_top() {
3622        let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
3623        layer.toggle();
3624        layer.set_banner_position(BannerPosition::Top);
3625
3626        let area = Rect::new(0, 0, 80, 24);
3627        let (app, banner) = layer.split_area(area);
3628
3629        assert_eq!(banner.y, 0);
3630        assert_eq!(banner.height, 1);
3631        assert_eq!(app.y, 1);
3632        assert_eq!(app.height, 23);
3633    }
3634
3635    #[test]
3636    fn test_inactive_layer() {
3637        let layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12)).active(false);
3638
3639        assert!(!layer.is_active());
3640        assert!(!layer.is_enabled());
3641    }
3642
3643    #[test]
3644    fn test_action_log() {
3645        let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
3646
3647        layer.log_action(&TestAction::Foo);
3648        layer.log_action(&TestAction::Bar);
3649
3650        assert_eq!(layer.action_log().entries().count(), 2);
3651    }
3652
3653    #[test]
3654    fn test_action_log_filter_configuration() {
3655        let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12))
3656            .with_action_log_filter(ActionLoggerConfig::new(Some("name:Foo"), None));
3657
3658        layer.log_action(&TestAction::Foo);
3659        layer.log_action(&TestAction::Bar);
3660
3661        let names: Vec<_> = layer
3662            .action_log()
3663            .entries()
3664            .map(|entry| entry.name)
3665            .collect();
3666        assert_eq!(names, vec!["Foo"]);
3667    }
3668
3669    #[test]
3670    fn test_action_log_search_input_does_not_trigger_global_shortcuts() {
3671        use crossterm::event::{KeyEventKind, KeyEventState};
3672        use tui_dispatch_core::EventKind;
3673
3674        let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
3675        layer.toggle();
3676        layer.show_action_log();
3677
3678        if let Some(DebugOverlay::ActionLog(log)) = layer.freeze.overlay.as_mut() {
3679            log.search_input_active = true;
3680        } else {
3681            panic!("expected action log overlay");
3682        }
3683
3684        let keys = [
3685            KeyEvent {
3686                code: KeyCode::Char('A'),
3687                modifiers: KeyModifiers::SHIFT,
3688                kind: KeyEventKind::Press,
3689                state: KeyEventState::NONE,
3690            },
3691            KeyEvent {
3692                code: KeyCode::Char('s'),
3693                modifiers: KeyModifiers::NONE,
3694                kind: KeyEventKind::Press,
3695                state: KeyEventState::NONE,
3696            },
3697            KeyEvent {
3698                code: KeyCode::Char('b'),
3699                modifiers: KeyModifiers::NONE,
3700                kind: KeyEventKind::Press,
3701                state: KeyEventState::NONE,
3702            },
3703            KeyEvent {
3704                code: KeyCode::Char('i'),
3705                modifiers: KeyModifiers::NONE,
3706                kind: KeyEventKind::Press,
3707                state: KeyEventState::NONE,
3708            },
3709        ];
3710        for key in keys {
3711            let outcome = layer.handle_event(&EventKind::Key(key));
3712            assert!(outcome.consumed);
3713        }
3714
3715        match layer.freeze.overlay.as_ref() {
3716            Some(DebugOverlay::ActionLog(log)) => {
3717                assert!(log.search_input_active);
3718                assert_eq!(log.search_query, "Asbi");
3719            }
3720            _ => panic!("action log overlay should remain open"),
3721        }
3722    }
3723
3724    #[test]
3725    fn test_esc_from_state_detail_returns_to_tree() {
3726        use crossterm::event::{KeyEventKind, KeyEventState};
3727        use tui_dispatch_core::EventKind;
3728
3729        let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
3730        layer.toggle();
3731
3732        let table = DebugTableBuilder::new()
3733            .section("State")
3734            .entry("key", "value")
3735            .finish("Application State");
3736        layer.set_state_overlay(table);
3737        layer.state_tree_selected = Some("entry:0:0:key".to_string());
3738        layer.handle_action(DebugAction::StateTreeShowDetail);
3739
3740        assert!(matches!(
3741            layer.freeze.overlay,
3742            Some(DebugOverlay::StateDetail(_))
3743        ));
3744
3745        let outcome = layer.handle_event(&EventKind::Key(KeyEvent {
3746            code: KeyCode::Esc,
3747            modifiers: KeyModifiers::NONE,
3748            kind: KeyEventKind::Press,
3749            state: KeyEventState::NONE,
3750        }));
3751
3752        assert!(outcome.consumed);
3753        assert!(layer.is_enabled());
3754        assert!(matches!(layer.freeze.overlay, Some(DebugOverlay::State(_))));
3755    }
3756
3757    #[test]
3758    fn test_esc_from_component_detail_returns_to_list() {
3759        use crossterm::event::{KeyEventKind, KeyEventState};
3760        use tui_dispatch_core::EventKind;
3761
3762        let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
3763        layer.toggle();
3764        layer.component_snapshotter = Some(Box::new(|| {
3765            vec![super::super::table::ComponentSnapshot {
3766                raw_id: 1,
3767                type_name: "Widget".to_string(),
3768                type_name_full: "crate::ui::Widget".to_string(),
3769                bound_id: Some("root".to_string()),
3770                last_area: None,
3771                debug_entries: vec![("state".to_string(), "Ready".to_string())],
3772            }]
3773        }));
3774        layer.show_components();
3775        layer.components_tree_selected = Some("comp:0".to_string());
3776        layer.handle_action(DebugAction::ComponentShowDetail);
3777
3778        assert!(matches!(
3779            layer.freeze.overlay,
3780            Some(DebugOverlay::ComponentDetail(_))
3781        ));
3782
3783        let outcome = layer.handle_event(&EventKind::Key(KeyEvent {
3784            code: KeyCode::Esc,
3785            modifiers: KeyModifiers::NONE,
3786            kind: KeyEventKind::Press,
3787            state: KeyEventState::NONE,
3788        }));
3789
3790        assert!(outcome.consumed);
3791        assert!(layer.is_enabled());
3792        assert!(matches!(
3793            layer.freeze.overlay,
3794            Some(DebugOverlay::Components(_))
3795        ));
3796    }
3797}