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