tui_dispatch_debug/debug/
layer.rs

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