tui_dispatch_core/debug/
layer.rs

1//! High-level debug layer for TUI applications
2//!
3//! Provides a wrapper that handles debug UI rendering with sensible defaults.
4
5use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
6use ratatui::buffer::Buffer;
7use ratatui::layout::Rect;
8use ratatui::style::Style;
9use ratatui::widgets::{Block, Borders, Clear};
10use ratatui::Frame;
11use std::marker::PhantomData;
12
13use super::action_logger::ActionLog;
14use super::actions::{DebugAction, DebugSideEffect};
15use super::cell::inspect_cell;
16use super::config::{
17    default_debug_keybindings, default_debug_keybindings_with_toggle, DebugConfig, DebugStyle,
18    StatusItem,
19};
20use super::state::DebugState;
21use super::table::{ActionLogOverlay, DebugOverlay, DebugTableBuilder, DebugTableOverlay};
22use super::widgets::{
23    dim_buffer, paint_snapshot, ActionLogWidget, BannerItem, CellPreviewWidget, DebugBanner,
24    DebugTableWidget,
25};
26use super::{DebugFreeze, SimpleDebugContext};
27use crate::keybindings::{format_key_for_display, BindingContext};
28
29/// High-level debug layer with sensible defaults
30///
31/// Wraps `DebugFreeze` and provides automatic rendering with:
32/// - 1-line status bar at bottom when debug mode is active
33/// - Frame capture/restore with dimming
34/// - Modal overlays for state inspection
35///
36/// # Type Parameters
37///
38/// - `A`: The application's action type (for queuing actions while frozen)
39/// - `C`: The keybinding context type
40///
41/// # Example
42///
43/// ```ignore
44/// use tui_dispatch::debug::{DebugLayer, DebugConfig};
45///
46/// // In your app:
47/// struct App {
48///     debug: DebugLayer<MyAction, MyContext>,
49///     // ...
50/// }
51///
52/// // In render loop:
53/// app.debug.render(frame, |f, area| {
54///     // Render your normal UI here
55///     app.render_main(f, area);
56/// });
57/// ```
58pub struct DebugLayer<A, C: BindingContext> {
59    /// Internal freeze state
60    freeze: DebugFreeze<A>,
61    /// Configuration
62    config: DebugConfig<C>,
63}
64
65impl<A, C: BindingContext> std::fmt::Debug for DebugLayer<A, C> {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.debug_struct("DebugLayer")
68            .field("enabled", &self.freeze.enabled)
69            .field("has_snapshot", &self.freeze.snapshot.is_some())
70            .field("queued_actions", &self.freeze.queued_actions.len())
71            .finish()
72    }
73}
74
75impl<A, C: BindingContext> DebugLayer<A, C> {
76    /// Create a new debug layer with the given configuration
77    pub fn new(config: DebugConfig<C>) -> Self {
78        Self {
79            freeze: DebugFreeze::new(),
80            config,
81        }
82    }
83
84    /// Check if debug mode is enabled
85    pub fn is_enabled(&self) -> bool {
86        self.freeze.enabled
87    }
88
89    /// Get a reference to the underlying freeze state
90    pub fn freeze(&self) -> &DebugFreeze<A> {
91        &self.freeze
92    }
93
94    /// Get a mutable reference to the underlying freeze state
95    pub fn freeze_mut(&mut self) -> &mut DebugFreeze<A> {
96        &mut self.freeze
97    }
98
99    /// Get the configuration
100    pub fn config(&self) -> &DebugConfig<C> {
101        &self.config
102    }
103
104    /// Get mutable configuration
105    pub fn config_mut(&mut self) -> &mut DebugConfig<C> {
106        &mut self.config
107    }
108
109    /// Render with automatic debug handling (primary API)
110    ///
111    /// When debug mode is disabled, simply calls `render_fn` with the full frame area.
112    ///
113    /// When debug mode is enabled:
114    /// - Reserves 1 line at bottom for the debug banner
115    /// - Captures the frame on first render or when requested
116    /// - Paints the frozen snapshot with dimming
117    /// - Renders debug overlay (banner + modal if open)
118    ///
119    /// # Example
120    ///
121    /// ```ignore
122    /// terminal.draw(|frame| {
123    ///     app.debug.render(frame, |f, area| {
124    ///         render_main_ui(f, area, &app.state);
125    ///     });
126    /// })?;
127    /// ```
128    pub fn render<F>(&mut self, frame: &mut Frame, render_fn: F)
129    where
130        F: FnOnce(&mut Frame, Rect),
131    {
132        let screen = frame.area();
133
134        if !self.freeze.enabled {
135            // Normal mode: render full screen
136            render_fn(frame, screen);
137            return;
138        }
139
140        // Debug mode: reserve bottom line for banner
141        let (app_area, banner_area) = self.split_for_banner(screen);
142
143        if self.freeze.pending_capture || self.freeze.snapshot.is_none() {
144            // Capture mode: render app, then capture
145            render_fn(frame, app_area);
146            // Clone the buffer for capture (buffer_mut is available, buffer is not public)
147            let buffer_clone = frame.buffer_mut().clone();
148            self.freeze.capture(&buffer_clone);
149        } else if let Some(ref snapshot) = self.freeze.snapshot {
150            // Frozen: paint snapshot
151            paint_snapshot(frame, snapshot);
152        }
153
154        // Render debug overlay
155        self.render_debug_overlay(frame, app_area, banner_area);
156    }
157
158    /// Split area for manual layout control (escape hatch)
159    ///
160    /// Returns (app_area, debug_banner_area). When debug mode is disabled,
161    /// returns the full area and an empty rect.
162    ///
163    /// # Example
164    ///
165    /// ```ignore
166    /// let (app_area, banner_area) = debug.split_area(frame.area());
167    ///
168    /// // Custom layout
169    /// let chunks = Layout::vertical([...]).split(app_area);
170    /// app.render_main(frame, chunks[0]);
171    /// app.render_status(frame, chunks[1]);
172    ///
173    /// // Let debug layer render its UI
174    /// if debug.is_enabled() {
175    ///     debug.render_overlay(frame, app_area);
176    ///     debug.render_banner(frame, banner_area);
177    /// }
178    /// ```
179    pub fn split_area(&self, area: Rect) -> (Rect, Rect) {
180        if !self.freeze.enabled {
181            return (area, Rect::ZERO);
182        }
183        self.split_for_banner(area)
184    }
185
186    /// Render just the debug overlay (modal + dimming)
187    ///
188    /// Use this with `split_area` for manual layout control.
189    pub fn render_overlay(&self, frame: &mut Frame, app_area: Rect) {
190        if !self.freeze.enabled {
191            return;
192        }
193
194        // Dim the background
195        dim_buffer(frame.buffer_mut(), self.config.style.dim_factor);
196
197        // Render overlay if present
198        if let Some(ref overlay) = self.freeze.overlay {
199            match overlay {
200                DebugOverlay::Inspect(table) | DebugOverlay::State(table) => {
201                    self.render_table_modal(frame, app_area, table);
202                }
203                DebugOverlay::ActionLog(log) => {
204                    self.render_action_log_modal(frame, app_area, log);
205                }
206            }
207        }
208    }
209
210    /// Render just the debug banner
211    ///
212    /// Use this with `split_area` for manual layout control.
213    pub fn render_banner(&self, frame: &mut Frame, banner_area: Rect) {
214        if !self.freeze.enabled || banner_area.height == 0 {
215            return;
216        }
217
218        let style = &self.config.style;
219        let mut banner = DebugBanner::new()
220            .title("DEBUG")
221            .title_style(style.title_style)
222            .label_style(style.label_style)
223            .background(style.banner_bg);
224
225        // Add standard debug commands with distinct colors
226        let keys = &style.key_styles;
227        self.add_banner_item(&mut banner, DebugAction::CMD_TOGGLE, "resume", keys.toggle);
228        self.add_banner_item(
229            &mut banner,
230            DebugAction::CMD_TOGGLE_ACTION_LOG,
231            "actions",
232            keys.actions,
233        );
234        self.add_banner_item(
235            &mut banner,
236            DebugAction::CMD_TOGGLE_STATE,
237            "state",
238            keys.state,
239        );
240        self.add_banner_item(&mut banner, DebugAction::CMD_COPY_FRAME, "copy", keys.copy);
241
242        if self.freeze.mouse_capture_enabled {
243            banner = banner.item(BannerItem::new("click", "inspect", keys.mouse));
244        } else {
245            self.add_banner_item(
246                &mut banner,
247                DebugAction::CMD_TOGGLE_MOUSE,
248                "mouse",
249                keys.mouse,
250            );
251        }
252
253        // Add message if present
254        if let Some(ref msg) = self.freeze.message {
255            banner = banner.item(BannerItem::new("", msg, style.value_style));
256        }
257
258        // Note: Custom status items from config.status_items() require owned strings
259        // but BannerItem uses borrowed &str. For now, use render_banner_with_status()
260        // if you need custom status items.
261
262        frame.render_widget(banner, banner_area);
263    }
264
265    /// Render the debug banner with custom status items
266    ///
267    /// Use this if you need to add dynamic status items to the banner.
268    /// The status_items slice must outlive this call.
269    pub fn render_banner_with_status(
270        &self,
271        frame: &mut Frame,
272        banner_area: Rect,
273        status_items: &[(&str, &str)],
274    ) {
275        if !self.freeze.enabled || banner_area.height == 0 {
276            return;
277        }
278
279        let style = &self.config.style;
280        let mut banner = DebugBanner::new()
281            .title("DEBUG")
282            .title_style(style.title_style)
283            .label_style(style.label_style)
284            .background(style.banner_bg);
285
286        // Add standard debug commands with distinct colors
287        let keys = &style.key_styles;
288        self.add_banner_item(&mut banner, DebugAction::CMD_TOGGLE, "resume", keys.toggle);
289        self.add_banner_item(
290            &mut banner,
291            DebugAction::CMD_TOGGLE_ACTION_LOG,
292            "actions",
293            keys.actions,
294        );
295        self.add_banner_item(
296            &mut banner,
297            DebugAction::CMD_TOGGLE_STATE,
298            "state",
299            keys.state,
300        );
301        self.add_banner_item(&mut banner, DebugAction::CMD_COPY_FRAME, "copy", keys.copy);
302
303        if self.freeze.mouse_capture_enabled {
304            banner = banner.item(BannerItem::new("click", "inspect", keys.mouse));
305        } else {
306            self.add_banner_item(
307                &mut banner,
308                DebugAction::CMD_TOGGLE_MOUSE,
309                "mouse",
310                keys.mouse,
311            );
312        }
313
314        // Add message if present
315        if let Some(ref msg) = self.freeze.message {
316            banner = banner.item(BannerItem::new("", msg, style.value_style));
317        }
318
319        // Add custom status items
320        for (label, value) in status_items {
321            banner = banner.item(BannerItem::new(label, value, style.value_style));
322        }
323
324        frame.render_widget(banner, banner_area);
325    }
326
327    /// Handle a debug action
328    ///
329    /// Returns a side effect if the app needs to take action (clipboard, mouse capture, etc).
330    pub fn handle_action(&mut self, action: DebugAction) -> Option<DebugSideEffect<A>> {
331        match action {
332            DebugAction::Toggle => {
333                if self.freeze.enabled {
334                    let queued = self.freeze.take_queued();
335                    self.freeze.disable();
336                    if queued.is_empty() {
337                        None
338                    } else {
339                        Some(DebugSideEffect::ProcessQueuedActions(queued))
340                    }
341                } else {
342                    self.freeze.enable();
343                    None
344                }
345            }
346            DebugAction::CopyFrame => {
347                let text = self.freeze.snapshot_text.clone();
348                self.freeze.set_message("Copied to clipboard");
349                Some(DebugSideEffect::CopyToClipboard(text))
350            }
351            DebugAction::ToggleState => {
352                // Toggle between no overlay and state overlay
353                if matches!(self.freeze.overlay, Some(DebugOverlay::State(_))) {
354                    self.freeze.clear_overlay();
355                } else {
356                    // App should call show_state_overlay() with their state
357                    // For now, just show a placeholder
358                    let table = DebugTableBuilder::new()
359                        .section("State")
360                        .entry("hint", "Call show_state_overlay() with your state")
361                        .finish("Application State");
362                    self.freeze.set_overlay(DebugOverlay::State(table));
363                }
364                None
365            }
366            DebugAction::ToggleActionLog => {
367                // Toggle between no overlay and action log overlay
368                if matches!(self.freeze.overlay, Some(DebugOverlay::ActionLog(_))) {
369                    self.freeze.clear_overlay();
370                } else {
371                    // App should call show_action_log() with their log
372                    // For now, just show a placeholder
373                    let overlay = ActionLogOverlay {
374                        title: "Action Log".to_string(),
375                        entries: vec![],
376                        selected: 0,
377                        scroll_offset: 0,
378                    };
379                    self.freeze
380                        .set_message("Call show_action_log() with your ActionLog");
381                    self.freeze.set_overlay(DebugOverlay::ActionLog(overlay));
382                }
383                None
384            }
385            DebugAction::ActionLogScrollUp => {
386                if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
387                    log.scroll_up();
388                }
389                None
390            }
391            DebugAction::ActionLogScrollDown => {
392                if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
393                    log.scroll_down();
394                }
395                None
396            }
397            DebugAction::ActionLogScrollTop => {
398                if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
399                    log.scroll_to_top();
400                }
401                None
402            }
403            DebugAction::ActionLogScrollBottom => {
404                if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
405                    log.scroll_to_bottom();
406                }
407                None
408            }
409            DebugAction::ToggleMouseCapture => {
410                self.freeze.toggle_mouse_capture();
411                if self.freeze.mouse_capture_enabled {
412                    Some(DebugSideEffect::EnableMouseCapture)
413                } else {
414                    Some(DebugSideEffect::DisableMouseCapture)
415                }
416            }
417            DebugAction::InspectCell { column, row } => {
418                if let Some(ref snapshot) = self.freeze.snapshot {
419                    let overlay = self.build_inspect_overlay(column, row, snapshot);
420                    self.freeze.set_overlay(DebugOverlay::Inspect(overlay));
421                }
422                self.freeze.mouse_capture_enabled = false;
423                Some(DebugSideEffect::DisableMouseCapture)
424            }
425            DebugAction::CloseOverlay => {
426                self.freeze.clear_overlay();
427                None
428            }
429            DebugAction::RequestCapture => {
430                self.freeze.request_capture();
431                None
432            }
433        }
434    }
435
436    /// Handle a mouse event in debug mode
437    ///
438    /// Returns `true` if the event was consumed by debug handling.
439    pub fn handle_mouse(&mut self, mouse: MouseEvent) -> Option<DebugSideEffect<A>> {
440        if !self.freeze.enabled {
441            return None;
442        }
443
444        // Ignore scroll events
445        if matches!(
446            mouse.kind,
447            MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
448        ) {
449            return None;
450        }
451
452        // Handle click in capture mode
453        if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
454            && self.freeze.mouse_capture_enabled
455        {
456            return self.handle_action(DebugAction::InspectCell {
457                column: mouse.column,
458                row: mouse.row,
459            });
460        }
461
462        None
463    }
464
465    /// Show state overlay using a DebugState implementor
466    pub fn show_state_overlay<S: DebugState>(&mut self, state: &S) {
467        let table = state.build_debug_table("Application State");
468        self.freeze.set_overlay(DebugOverlay::State(table));
469    }
470
471    /// Show state overlay with custom title
472    pub fn show_state_overlay_with_title<S: DebugState>(&mut self, state: &S, title: &str) {
473        let table = state.build_debug_table(title);
474        self.freeze.set_overlay(DebugOverlay::State(table));
475    }
476
477    /// Show action log overlay
478    ///
479    /// Displays recent actions from the provided ActionLog.
480    ///
481    /// # Example
482    ///
483    /// ```ignore
484    /// // If using ActionLoggerMiddleware with storage
485    /// if let Some(log) = middleware.log() {
486    ///     debug_layer.show_action_log(log);
487    /// }
488    /// ```
489    pub fn show_action_log(&mut self, log: &ActionLog) {
490        let overlay = ActionLogOverlay::from_log(log, "Action Log");
491        self.freeze.set_overlay(DebugOverlay::ActionLog(overlay));
492    }
493
494    /// Show action log overlay with custom title
495    pub fn show_action_log_with_title(&mut self, log: &ActionLog, title: &str) {
496        let overlay = ActionLogOverlay::from_log(log, title);
497        self.freeze.set_overlay(DebugOverlay::ActionLog(overlay));
498    }
499
500    /// Queue an action to be processed when debug mode is disabled
501    pub fn queue_action(&mut self, action: A) {
502        self.freeze.queue(action);
503    }
504
505    // --- Private helpers ---
506
507    fn split_for_banner(&self, area: Rect) -> (Rect, Rect) {
508        let banner_height = 1;
509        let app_area = Rect {
510            height: area.height.saturating_sub(banner_height),
511            ..area
512        };
513        let banner_area = Rect {
514            y: area.y.saturating_add(app_area.height),
515            height: banner_height.min(area.height),
516            ..area
517        };
518        (app_area, banner_area)
519    }
520
521    fn render_debug_overlay(&self, frame: &mut Frame, app_area: Rect, banner_area: Rect) {
522        // Dim the background
523        dim_buffer(frame.buffer_mut(), self.config.style.dim_factor);
524
525        // Render overlay if present
526        if let Some(ref overlay) = self.freeze.overlay {
527            match overlay {
528                DebugOverlay::Inspect(table) | DebugOverlay::State(table) => {
529                    self.render_table_modal(frame, app_area, table);
530                }
531                DebugOverlay::ActionLog(log) => {
532                    self.render_action_log_modal(frame, app_area, log);
533                }
534            }
535        }
536
537        // Render banner
538        self.render_banner(frame, banner_area);
539    }
540
541    fn render_table_modal(&self, frame: &mut Frame, app_area: Rect, table: &DebugTableOverlay) {
542        // Calculate modal size (80% width, 60% height, with min/max)
543        let modal_width = (app_area.width * 80 / 100)
544            .clamp(30, 120)
545            .min(app_area.width);
546        let modal_height = (app_area.height * 60 / 100)
547            .clamp(8, 40)
548            .min(app_area.height);
549
550        // Center the modal
551        let modal_x = app_area.x + (app_area.width.saturating_sub(modal_width)) / 2;
552        let modal_y = app_area.y + (app_area.height.saturating_sub(modal_height)) / 2;
553
554        let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
555
556        // Clear and render modal background
557        frame.render_widget(Clear, modal_area);
558
559        let block = Block::default()
560            .borders(Borders::ALL)
561            .title(format!(" {} ", table.title))
562            .style(self.config.style.banner_bg);
563
564        let inner = block.inner(modal_area);
565        frame.render_widget(block, modal_area);
566
567        // Cell preview on top (if present), table below
568        if let Some(ref preview) = table.cell_preview {
569            if inner.height > 3 {
570                let preview_height = 2u16; // 1 line + 1 spacing
571                let preview_area = Rect {
572                    x: inner.x,
573                    y: inner.y,
574                    width: inner.width,
575                    height: 1,
576                };
577                let table_area = Rect {
578                    x: inner.x,
579                    y: inner.y.saturating_add(preview_height),
580                    width: inner.width,
581                    height: inner.height.saturating_sub(preview_height),
582                };
583
584                // Render cell preview with neon colors
585                let preview_widget = CellPreviewWidget::new(preview)
586                    .label_style(Style::default().fg(DebugStyle::text_secondary()))
587                    .value_style(Style::default().fg(DebugStyle::text_primary()));
588                frame.render_widget(preview_widget, preview_area);
589
590                // Render table below
591                let table_widget = DebugTableWidget::new(table);
592                frame.render_widget(table_widget, table_area);
593                return;
594            }
595        }
596
597        // No cell preview or not enough space - just render table
598        let table_widget = DebugTableWidget::new(table);
599        frame.render_widget(table_widget, inner);
600    }
601
602    fn render_action_log_modal(&self, frame: &mut Frame, app_area: Rect, log: &ActionLogOverlay) {
603        // Calculate modal size (larger for action log - 90% width, 70% height)
604        let modal_width = (app_area.width * 90 / 100)
605            .clamp(40, 140)
606            .min(app_area.width);
607        let modal_height = (app_area.height * 70 / 100)
608            .clamp(10, 50)
609            .min(app_area.height);
610
611        // Center the modal
612        let modal_x = app_area.x + (app_area.width.saturating_sub(modal_width)) / 2;
613        let modal_y = app_area.y + (app_area.height.saturating_sub(modal_height)) / 2;
614
615        let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
616
617        // Clear and render modal background
618        frame.render_widget(Clear, modal_area);
619
620        let entry_count = log.entries.len();
621        let title = if entry_count > 0 {
622            format!(" {} ({} entries) ", log.title, entry_count)
623        } else {
624            format!(" {} (empty) ", log.title)
625        };
626
627        let block = Block::default()
628            .borders(Borders::ALL)
629            .title(title)
630            .style(self.config.style.banner_bg);
631
632        let inner = block.inner(modal_area);
633        frame.render_widget(block, modal_area);
634
635        // Render action log widget
636        let widget = ActionLogWidget::new(log);
637        frame.render_widget(widget, inner);
638    }
639
640    fn add_banner_item(
641        &self,
642        banner: &mut DebugBanner<'_>,
643        command: &str,
644        label: &'static str,
645        style: Style,
646    ) {
647        if let Some(key) = self
648            .config
649            .keybindings
650            .get_first_keybinding(command, self.config.debug_context)
651        {
652            let formatted = format_key_for_display(&key);
653            // We need to leak the string for the lifetime - this is fine for debug UI
654            let key_str: &'static str = Box::leak(formatted.into_boxed_str());
655            *banner = std::mem::take(banner).item(BannerItem::new(key_str, label, style));
656        }
657    }
658
659    fn build_inspect_overlay(&self, column: u16, row: u16, snapshot: &Buffer) -> DebugTableOverlay {
660        let mut builder = DebugTableBuilder::new();
661
662        builder.push_section("Position");
663        builder.push_entry("column", column.to_string());
664        builder.push_entry("row", row.to_string());
665
666        if let Some(preview) = inspect_cell(snapshot, column, row) {
667            builder.set_cell_preview(preview);
668        }
669
670        builder.finish(format!("Inspect ({column}, {row})"))
671    }
672}
673
674/// Builder for DebugLayer with ergonomic configuration
675pub struct DebugLayerBuilder<A, C: BindingContext> {
676    config: DebugConfig<C>,
677    _marker: PhantomData<A>,
678}
679
680impl<A, C: BindingContext> DebugLayerBuilder<A, C> {
681    /// Create a new builder
682    pub fn new(config: DebugConfig<C>) -> Self {
683        Self {
684            config,
685            _marker: PhantomData,
686        }
687    }
688
689    /// Set status provider
690    pub fn with_status_provider<F>(mut self, provider: F) -> Self
691    where
692        F: Fn() -> Vec<StatusItem> + Send + Sync + 'static,
693    {
694        self.config = self.config.with_status_provider(provider);
695        self
696    }
697
698    /// Build the DebugLayer
699    pub fn build(self) -> DebugLayer<A, C> {
700        DebugLayer::new(self.config)
701    }
702}
703
704// ============================================================================
705// Simple API - Zero-configuration debug layer
706// ============================================================================
707
708impl<A> DebugLayer<A, SimpleDebugContext> {
709    /// Create a debug layer with sensible defaults - no configuration needed.
710    ///
711    /// This is the recommended way to add debug capabilities to your app.
712    ///
713    /// # Default Keybindings (when debug mode is active)
714    ///
715    /// - `F12` / `Esc`: Toggle debug mode
716    /// - `S`: Show/hide state overlay
717    /// - `Y`: Copy frozen frame to clipboard
718    /// - `I`: Toggle mouse capture for cell inspection
719    ///
720    /// # Example
721    ///
722    /// ```ignore
723    /// use tui_dispatch::debug::DebugLayer;
724    ///
725    /// // One line setup:
726    /// let mut debug = DebugLayer::<MyAction>::simple();
727    ///
728    /// // In render loop:
729    /// terminal.draw(|frame| {
730    ///     debug.render(frame, |f, area| {
731    ///         render_my_app(f, area, &state);
732    ///     });
733    /// })?;
734    ///
735    /// // Handle F12 to toggle (before normal event handling):
736    /// if matches!(event, KeyEvent { code: KeyCode::F(12), .. }) {
737    ///     debug.handle_action(DebugAction::Toggle);
738    /// }
739    /// ```
740    pub fn simple() -> Self {
741        let keybindings = default_debug_keybindings();
742        let config = DebugConfig::new(keybindings, SimpleDebugContext::Debug);
743        Self::new(config)
744    }
745
746    /// Create a debug layer with custom toggle key(s).
747    ///
748    /// Same as [`simple()`](Self::simple) but uses the provided key(s)
749    /// for toggling debug mode instead of `F12`/`Esc`.
750    ///
751    /// # Example
752    ///
753    /// ```ignore
754    /// use tui_dispatch::debug::DebugLayer;
755    ///
756    /// // Use F11 instead of F12:
757    /// let debug = DebugLayer::<MyAction>::simple_with_toggle_key(&["F11"]);
758    ///
759    /// // Multiple toggle keys:
760    /// let debug = DebugLayer::<MyAction>::simple_with_toggle_key(&["F11", "Ctrl+D"]);
761    /// ```
762    pub fn simple_with_toggle_key(keys: &[&str]) -> Self {
763        let keybindings = default_debug_keybindings_with_toggle(keys);
764        let config = DebugConfig::new(keybindings, SimpleDebugContext::Debug);
765        Self::new(config)
766    }
767}
768
769#[cfg(test)]
770mod tests {
771    use super::*;
772    use crate::keybindings::Keybindings;
773
774    // Minimal test context
775    #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
776    enum TestContext {
777        Debug,
778    }
779
780    impl BindingContext for TestContext {
781        fn name(&self) -> &'static str {
782            "debug"
783        }
784        fn from_name(name: &str) -> Option<Self> {
785            (name == "debug").then_some(TestContext::Debug)
786        }
787        fn all() -> &'static [Self] {
788            &[TestContext::Debug]
789        }
790    }
791
792    #[derive(Debug, Clone)]
793    enum TestAction {
794        Foo,
795        Bar,
796    }
797
798    fn make_layer() -> DebugLayer<TestAction, TestContext> {
799        let config = DebugConfig::new(Keybindings::new(), TestContext::Debug);
800        DebugLayer::new(config)
801    }
802
803    #[test]
804    fn test_debug_layer_creation() {
805        let layer = make_layer();
806        assert!(!layer.is_enabled());
807        assert!(layer.freeze().snapshot.is_none());
808    }
809
810    #[test]
811    fn test_toggle() {
812        let mut layer = make_layer();
813
814        // Enable
815        let effect = layer.handle_action(DebugAction::Toggle);
816        assert!(effect.is_none());
817        assert!(layer.is_enabled());
818
819        // Disable
820        let effect = layer.handle_action(DebugAction::Toggle);
821        assert!(effect.is_none()); // No queued actions
822        assert!(!layer.is_enabled());
823    }
824
825    #[test]
826    fn test_queued_actions_returned_on_disable() {
827        let mut layer = make_layer();
828
829        layer.handle_action(DebugAction::Toggle); // Enable
830        layer.queue_action(TestAction::Foo);
831        layer.queue_action(TestAction::Bar);
832
833        let effect = layer.handle_action(DebugAction::Toggle); // Disable
834
835        match effect {
836            Some(DebugSideEffect::ProcessQueuedActions(actions)) => {
837                assert_eq!(actions.len(), 2);
838            }
839            _ => panic!("Expected ProcessQueuedActions"),
840        }
841    }
842
843    #[test]
844    fn test_split_area() {
845        let layer = make_layer();
846
847        // Disabled: full area returned
848        let area = Rect::new(0, 0, 80, 24);
849        let (app, banner) = layer.split_area(area);
850        assert_eq!(app, area);
851        assert_eq!(banner, Rect::ZERO);
852    }
853
854    #[test]
855    fn test_split_area_enabled() {
856        let mut layer = make_layer();
857        layer.handle_action(DebugAction::Toggle);
858
859        let area = Rect::new(0, 0, 80, 24);
860        let (app, banner) = layer.split_area(area);
861
862        assert_eq!(app.height, 23);
863        assert_eq!(banner.height, 1);
864        assert_eq!(banner.y, 23);
865    }
866
867    #[test]
868    fn test_mouse_capture_toggle() {
869        let mut layer = make_layer();
870        layer.handle_action(DebugAction::Toggle); // Enable debug
871
872        let effect = layer.handle_action(DebugAction::ToggleMouseCapture);
873        assert!(matches!(effect, Some(DebugSideEffect::EnableMouseCapture)));
874        assert!(layer.freeze().mouse_capture_enabled);
875
876        let effect = layer.handle_action(DebugAction::ToggleMouseCapture);
877        assert!(matches!(effect, Some(DebugSideEffect::DisableMouseCapture)));
878        assert!(!layer.freeze().mouse_capture_enabled);
879    }
880
881    // Tests for simple() API
882    #[test]
883    fn test_simple_creation() {
884        let layer: DebugLayer<TestAction, SimpleDebugContext> = DebugLayer::simple();
885        assert!(!layer.is_enabled());
886        assert!(layer.freeze().snapshot.is_none());
887
888        // Verify config uses SimpleDebugContext::Debug
889        assert_eq!(layer.config().debug_context, SimpleDebugContext::Debug);
890    }
891
892    #[test]
893    fn test_simple_toggle_works() {
894        let mut layer: DebugLayer<TestAction, SimpleDebugContext> = DebugLayer::simple();
895
896        // Enable
897        layer.handle_action(DebugAction::Toggle);
898        assert!(layer.is_enabled());
899
900        // Disable
901        layer.handle_action(DebugAction::Toggle);
902        assert!(!layer.is_enabled());
903    }
904
905    #[test]
906    fn test_simple_with_toggle_key() {
907        let layer: DebugLayer<TestAction, SimpleDebugContext> =
908            DebugLayer::simple_with_toggle_key(&["F11"]);
909
910        assert!(!layer.is_enabled());
911
912        // Check that F11 is registered (by checking keybindings)
913        let keybindings = &layer.config().keybindings;
914        let toggle_keys = keybindings.get_context_bindings(SimpleDebugContext::Debug);
915        assert!(toggle_keys.is_some());
916
917        if let Some(bindings) = toggle_keys {
918            let keys = bindings.get("debug.toggle");
919            assert!(keys.is_some());
920            assert!(keys.unwrap().contains(&"F11".to_string()));
921        }
922    }
923
924    #[test]
925    fn test_simple_has_default_keybindings() {
926        let layer: DebugLayer<TestAction, SimpleDebugContext> = DebugLayer::simple();
927        let keybindings = &layer.config().keybindings;
928
929        // Check all default bindings are present
930        let bindings = keybindings
931            .get_context_bindings(SimpleDebugContext::Debug)
932            .unwrap();
933
934        assert!(bindings.contains_key("debug.toggle"));
935        assert!(bindings.contains_key("debug.state"));
936        assert!(bindings.contains_key("debug.copy"));
937        assert!(bindings.contains_key("debug.mouse"));
938
939        // Check toggle has F12 and Esc
940        let toggle_keys = bindings.get("debug.toggle").unwrap();
941        assert!(toggle_keys.contains(&"F12".to_string()));
942        assert!(toggle_keys.contains(&"Esc".to_string()));
943    }
944}