Skip to main content

tui_dispatch_debug/debug/
layer.rs

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