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