tui_dispatch_core/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::io::Write;
7
8use base64::prelude::*;
9use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEventKind};
10use ratatui::buffer::Buffer;
11use ratatui::layout::Rect;
12use ratatui::style::Style;
13use ratatui::widgets::{Block, Borders, Clear};
14use ratatui::Frame;
15
16use super::action_logger::{ActionLog, ActionLogConfig};
17use super::actions::{DebugAction, DebugSideEffect};
18use super::cell::inspect_cell;
19use super::config::DebugStyle;
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;
27#[cfg(feature = "subscriptions")]
28use crate::subscriptions::SubPauseHandle;
29#[cfg(feature = "tasks")]
30use crate::tasks::TaskPauseHandle;
31use crate::Action;
32
33/// High-level debug layer with minimal configuration.
34///
35/// Provides automatic freeze/unfreeze with pause/resume of tasks and subscriptions.
36///
37/// # Example
38///
39/// ```ignore
40/// use crossterm::event::KeyCode;
41/// use tui_dispatch::debug::DebugLayer;
42///
43/// // Minimal setup - just the toggle key
44/// let mut debug = DebugLayer::new(KeyCode::F(12))
45///     .with_task_manager(&tasks)
46///     .with_subscriptions(&subs)
47///     .active(args.debug);
48///
49/// // In event loop
50/// if debug.intercepts(&event) {
51///     continue;
52/// }
53///
54/// // In render
55/// debug.render(frame, |f, area| {
56///     app.render(f, area);
57/// });
58///
59/// // Log actions for the action log feature
60/// debug.log_action(&action);
61/// ```
62pub struct DebugLayer<A> {
63    /// Key to toggle debug mode
64    toggle_key: KeyCode,
65    /// Internal freeze state
66    freeze: DebugFreeze<A>,
67    /// Style configuration
68    style: DebugStyle,
69    /// Whether the debug layer is active (can be disabled for release builds)
70    active: bool,
71    /// Action log for display
72    action_log: ActionLog,
73    /// Handle to pause/resume task manager
74    #[cfg(feature = "tasks")]
75    task_handle: Option<TaskPauseHandle<A>>,
76    /// Handle to pause/resume subscriptions
77    #[cfg(feature = "subscriptions")]
78    sub_handle: Option<SubPauseHandle>,
79}
80
81impl<A> std::fmt::Debug for DebugLayer<A> {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.debug_struct("DebugLayer")
84            .field("toggle_key", &self.toggle_key)
85            .field("active", &self.active)
86            .field("enabled", &self.freeze.enabled)
87            .field("has_snapshot", &self.freeze.snapshot.is_some())
88            .field("queued_actions", &self.freeze.queued_actions.len())
89            .finish()
90    }
91}
92
93impl<A: Action> DebugLayer<A> {
94    /// Create a new debug layer with the given toggle key.
95    ///
96    /// # Example
97    ///
98    /// ```ignore
99    /// use crossterm::event::KeyCode;
100    ///
101    /// let debug = DebugLayer::new(KeyCode::F(12));
102    /// ```
103    pub fn new(toggle_key: KeyCode) -> Self {
104        Self {
105            toggle_key,
106            freeze: DebugFreeze::new(),
107            style: DebugStyle::default(),
108            active: true,
109            action_log: ActionLog::new(ActionLogConfig::with_capacity(100)),
110            #[cfg(feature = "tasks")]
111            task_handle: None,
112            #[cfg(feature = "subscriptions")]
113            sub_handle: None,
114        }
115    }
116
117    /// Set whether the debug layer is active.
118    ///
119    /// When inactive (`false`), all methods become no-ops with zero overhead.
120    pub fn active(mut self, active: bool) -> Self {
121        self.active = active;
122        self
123    }
124
125    /// Connect a task manager for automatic pause/resume.
126    ///
127    /// When debug mode is enabled, the task manager will be paused.
128    /// When disabled, queued actions will be returned.
129    #[cfg(feature = "tasks")]
130    pub fn with_task_manager(mut self, tasks: &crate::tasks::TaskManager<A>) -> Self {
131        self.task_handle = Some(tasks.pause_handle());
132        self
133    }
134
135    /// Connect subscriptions for automatic pause/resume.
136    ///
137    /// When debug mode is enabled, subscriptions will be paused.
138    #[cfg(feature = "subscriptions")]
139    pub fn with_subscriptions(mut self, subs: &crate::subscriptions::Subscriptions<A>) -> Self {
140        self.sub_handle = Some(subs.pause_handle());
141        self
142    }
143
144    /// Set the action log capacity.
145    pub fn with_action_log_capacity(mut self, capacity: usize) -> Self {
146        self.action_log = ActionLog::new(ActionLogConfig::with_capacity(capacity));
147        self
148    }
149
150    /// Set custom style.
151    pub fn with_style(mut self, style: DebugStyle) -> Self {
152        self.style = style;
153        self
154    }
155
156    /// Check if the debug layer is active.
157    pub fn is_active(&self) -> bool {
158        self.active
159    }
160
161    /// Check if debug mode is enabled (and layer is active).
162    pub fn is_enabled(&self) -> bool {
163        self.active && self.freeze.enabled
164    }
165
166    /// Check if the state overlay is currently visible.
167    pub fn is_state_overlay_visible(&self) -> bool {
168        matches!(self.freeze.overlay, Some(DebugOverlay::State(_)))
169    }
170
171    /// Get a reference to the underlying freeze state.
172    pub fn freeze(&self) -> &DebugFreeze<A> {
173        &self.freeze
174    }
175
176    /// Get a mutable reference to the underlying freeze state.
177    pub fn freeze_mut(&mut self) -> &mut DebugFreeze<A> {
178        &mut self.freeze
179    }
180
181    /// Log an action to the action log.
182    ///
183    /// Call this when dispatching actions to record them for the debug overlay.
184    pub fn log_action<T: crate::ActionParams>(&mut self, action: &T) {
185        if self.active {
186            self.action_log.log(action);
187        }
188    }
189
190    /// Get the action log.
191    pub fn action_log(&self) -> &ActionLog {
192        &self.action_log
193    }
194
195    /// Render with automatic debug handling.
196    ///
197    /// When debug mode is disabled, simply calls `render_fn` with the full frame area.
198    /// When enabled, captures/paints the frozen snapshot and renders debug overlay.
199    pub fn render<F>(&mut self, frame: &mut Frame, render_fn: F)
200    where
201        F: FnOnce(&mut Frame, Rect),
202    {
203        let screen = frame.area();
204
205        // Inactive or not in debug mode: just render normally
206        if !self.active || !self.freeze.enabled {
207            render_fn(frame, screen);
208            return;
209        }
210
211        // Debug mode: reserve bottom line for banner
212        let (app_area, banner_area) = self.split_for_banner(screen);
213
214        if self.freeze.pending_capture || self.freeze.snapshot.is_none() {
215            // Capture mode: render app, then capture
216            render_fn(frame, app_area);
217            let buffer_clone = frame.buffer_mut().clone();
218            self.freeze.capture(&buffer_clone);
219        } else if let Some(ref snapshot) = self.freeze.snapshot {
220            // Frozen: paint snapshot
221            paint_snapshot(frame, snapshot);
222        }
223
224        // Render debug overlay
225        self.render_debug_overlay(frame, app_area, banner_area);
226    }
227
228    /// Split area for manual layout control.
229    pub fn split_area(&self, area: Rect) -> (Rect, Rect) {
230        if !self.freeze.enabled {
231            return (area, Rect::ZERO);
232        }
233        self.split_for_banner(area)
234    }
235
236    /// Check if debug layer intercepts an event.
237    ///
238    /// Call this before your normal event handling. If it returns `true`,
239    /// the event was consumed by the debug layer.
240    ///
241    /// # Example
242    ///
243    /// ```ignore
244    /// if debug.intercepts(&event) {
245    ///     continue;
246    /// }
247    /// // Normal event handling
248    /// ```
249    pub fn intercepts(&mut self, event: &crate::EventKind) -> bool {
250        self.intercepts_with_effects(event).is_some()
251    }
252
253    /// Check if debug layer intercepts an event, returning any side effects.
254    ///
255    /// Returns `None` if the event was not consumed, `Some(effects)` if it was.
256    pub fn intercepts_with_effects(
257        &mut self,
258        event: &crate::EventKind,
259    ) -> Option<Vec<DebugSideEffect<A>>> {
260        if !self.active {
261            return None;
262        }
263
264        use crate::EventKind;
265
266        match event {
267            EventKind::Key(key) => self.handle_key_event(*key),
268            EventKind::Mouse(mouse) => {
269                if !self.freeze.enabled {
270                    return None;
271                }
272
273                // Only capture mouse when mouse_capture_enabled (toggle with 'i')
274                // When disabled, let terminal handle mouse (allows text selection)
275                if !self.freeze.mouse_capture_enabled {
276                    return None;
277                }
278
279                // Handle click for cell inspection
280                if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
281                    let effect = self.handle_action(DebugAction::InspectCell {
282                        column: mouse.column,
283                        row: mouse.row,
284                    });
285                    return Some(effect.into_iter().collect());
286                }
287
288                // Consume mouse events when capturing
289                Some(vec![])
290            }
291            EventKind::Scroll { delta, .. } => {
292                if !self.freeze.enabled {
293                    return None;
294                }
295
296                // Handle scrolling in action log overlay
297                if let Some(DebugOverlay::ActionLog(_)) = self.freeze.overlay {
298                    let action = if *delta > 0 {
299                        DebugAction::ActionLogScrollUp
300                    } else {
301                        DebugAction::ActionLogScrollDown
302                    };
303                    self.handle_action(action);
304                }
305
306                Some(vec![])
307            }
308            // Don't intercept resize or tick events
309            EventKind::Resize(_, _) | EventKind::Tick => None,
310        }
311    }
312
313    /// Show state overlay using a DebugState implementor.
314    pub fn show_state_overlay<S: DebugState>(&mut self, state: &S) {
315        let table = state.build_debug_table("Application State");
316        self.freeze.set_overlay(DebugOverlay::State(table));
317    }
318
319    /// Show action log overlay.
320    pub fn show_action_log(&mut self) {
321        let overlay = ActionLogOverlay::from_log(&self.action_log, "Action Log");
322        self.freeze.set_overlay(DebugOverlay::ActionLog(overlay));
323    }
324
325    /// Queue an action to be processed when debug mode is disabled.
326    pub fn queue_action(&mut self, action: A) {
327        self.freeze.queue(action);
328    }
329
330    /// Take any queued actions (from task manager resume).
331    ///
332    /// Call this after `intercepts()` returns effects to get queued actions
333    /// that should be dispatched.
334    pub fn take_queued_actions(&mut self) -> Vec<A> {
335        std::mem::take(&mut self.freeze.queued_actions)
336    }
337
338    // =========================================================================
339    // Private helpers
340    // =========================================================================
341
342    fn handle_key_event(&mut self, key: KeyEvent) -> Option<Vec<DebugSideEffect<A>>> {
343        // Toggle key always works (even when disabled)
344        if key.code == self.toggle_key && key.modifiers.is_empty() {
345            let effect = self.toggle();
346            return Some(effect.into_iter().collect());
347        }
348
349        // Esc also toggles off when enabled
350        if self.freeze.enabled && key.code == KeyCode::Esc {
351            let effect = self.toggle();
352            return Some(effect.into_iter().collect());
353        }
354
355        // Other commands only work when enabled
356        if !self.freeze.enabled {
357            return None;
358        }
359
360        // Handle internal debug commands (hardcoded keys)
361        let action = match key.code {
362            KeyCode::Char('s') | KeyCode::Char('S') => Some(DebugAction::ToggleState),
363            KeyCode::Char('a') | KeyCode::Char('A') => Some(DebugAction::ToggleActionLog),
364            KeyCode::Char('y') | KeyCode::Char('Y') => Some(DebugAction::CopyFrame),
365            KeyCode::Char('i') | KeyCode::Char('I') => Some(DebugAction::ToggleMouseCapture),
366            KeyCode::Char('q') | KeyCode::Char('Q') => Some(DebugAction::CloseOverlay),
367            _ => None,
368        };
369
370        if let Some(action) = action {
371            let effect = self.handle_action(action);
372            return Some(effect.into_iter().collect());
373        }
374
375        // Handle overlay-specific navigation
376        match &self.freeze.overlay {
377            Some(DebugOverlay::ActionLog(_)) => {
378                let action = match key.code {
379                    KeyCode::Char('j') | KeyCode::Down => Some(DebugAction::ActionLogScrollDown),
380                    KeyCode::Char('k') | KeyCode::Up => Some(DebugAction::ActionLogScrollUp),
381                    KeyCode::Char('g') => Some(DebugAction::ActionLogScrollTop),
382                    KeyCode::Char('G') => Some(DebugAction::ActionLogScrollBottom),
383                    KeyCode::PageDown => Some(DebugAction::ActionLogPageDown),
384                    KeyCode::PageUp => Some(DebugAction::ActionLogPageUp),
385                    KeyCode::Enter => Some(DebugAction::ActionLogShowDetail),
386                    _ => None,
387                };
388                if let Some(action) = action {
389                    self.handle_action(action);
390                    return Some(vec![]);
391                }
392            }
393            Some(DebugOverlay::ActionDetail(_)) => {
394                // Back to action log on Esc, Backspace, or Enter
395                if matches!(key.code, KeyCode::Esc | KeyCode::Backspace | KeyCode::Enter) {
396                    self.handle_action(DebugAction::ActionLogBackToList);
397                    return Some(vec![]);
398                }
399            }
400            _ => {}
401        }
402
403        // Consume all key events when debug is enabled
404        Some(vec![])
405    }
406
407    fn toggle(&mut self) -> Option<DebugSideEffect<A>> {
408        if self.freeze.enabled {
409            // Disable: resume tasks/subs
410            #[cfg(feature = "subscriptions")]
411            if let Some(ref handle) = self.sub_handle {
412                handle.resume();
413            }
414
415            #[cfg(feature = "tasks")]
416            let task_queued = if let Some(ref handle) = self.task_handle {
417                handle.resume()
418            } else {
419                vec![]
420            };
421            #[cfg(not(feature = "tasks"))]
422            let task_queued: Vec<A> = vec![];
423
424            let queued = self.freeze.take_queued();
425            self.freeze.disable();
426
427            // Combine queued actions from freeze and task manager
428            let mut all_queued = queued;
429            all_queued.extend(task_queued);
430
431            if all_queued.is_empty() {
432                None
433            } else {
434                Some(DebugSideEffect::ProcessQueuedActions(all_queued))
435            }
436        } else {
437            // Enable: pause tasks/subs
438            #[cfg(feature = "tasks")]
439            if let Some(ref handle) = self.task_handle {
440                handle.pause();
441            }
442            #[cfg(feature = "subscriptions")]
443            if let Some(ref handle) = self.sub_handle {
444                handle.pause();
445            }
446            self.freeze.enable();
447            None
448        }
449    }
450
451    fn handle_action(&mut self, action: DebugAction) -> Option<DebugSideEffect<A>> {
452        match action {
453            DebugAction::Toggle => self.toggle(),
454            DebugAction::CopyFrame => {
455                let text = &self.freeze.snapshot_text;
456                // Use OSC52 escape sequence to copy to clipboard
457                let encoded = BASE64_STANDARD.encode(text);
458                print!("\x1b]52;c;{}\x07", encoded);
459                std::io::stdout().flush().ok();
460                self.freeze.set_message("Copied to clipboard");
461                None
462            }
463            DebugAction::ToggleState => {
464                if matches!(self.freeze.overlay, Some(DebugOverlay::State(_))) {
465                    self.freeze.clear_overlay();
466                } else {
467                    // Show placeholder - user should call show_state_overlay()
468                    let table = DebugTableBuilder::new()
469                        .section("State")
470                        .entry("hint", "Press 's' after calling show_state_overlay()")
471                        .finish("Application State");
472                    self.freeze.set_overlay(DebugOverlay::State(table));
473                }
474                None
475            }
476            DebugAction::ToggleActionLog => {
477                if matches!(self.freeze.overlay, Some(DebugOverlay::ActionLog(_))) {
478                    self.freeze.clear_overlay();
479                } else {
480                    self.show_action_log();
481                }
482                None
483            }
484            DebugAction::ActionLogScrollUp => {
485                if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
486                    log.scroll_up();
487                }
488                None
489            }
490            DebugAction::ActionLogScrollDown => {
491                if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
492                    log.scroll_down();
493                }
494                None
495            }
496            DebugAction::ActionLogScrollTop => {
497                if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
498                    log.scroll_to_top();
499                }
500                None
501            }
502            DebugAction::ActionLogScrollBottom => {
503                if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
504                    log.scroll_to_bottom();
505                }
506                None
507            }
508            DebugAction::ActionLogPageUp => {
509                if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
510                    log.page_up(10);
511                }
512                None
513            }
514            DebugAction::ActionLogPageDown => {
515                if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
516                    log.page_down(10);
517                }
518                None
519            }
520            DebugAction::ActionLogShowDetail => {
521                if let Some(DebugOverlay::ActionLog(ref log)) = self.freeze.overlay {
522                    if let Some(detail) = log.selected_detail() {
523                        self.freeze.set_overlay(DebugOverlay::ActionDetail(detail));
524                    }
525                }
526                None
527            }
528            DebugAction::ActionLogBackToList => {
529                // Go back to action log from detail view
530                if matches!(self.freeze.overlay, Some(DebugOverlay::ActionDetail(_))) {
531                    self.show_action_log();
532                }
533                None
534            }
535            DebugAction::ToggleMouseCapture => {
536                self.freeze.toggle_mouse_capture();
537                None
538            }
539            DebugAction::InspectCell { column, row } => {
540                if let Some(ref snapshot) = self.freeze.snapshot {
541                    let overlay = self.build_inspect_overlay(column, row, snapshot);
542                    self.freeze.set_overlay(DebugOverlay::Inspect(overlay));
543                }
544                self.freeze.mouse_capture_enabled = false;
545                None
546            }
547            DebugAction::CloseOverlay => {
548                self.freeze.clear_overlay();
549                None
550            }
551            DebugAction::RequestCapture => {
552                self.freeze.request_capture();
553                None
554            }
555        }
556    }
557
558    fn split_for_banner(&self, area: Rect) -> (Rect, Rect) {
559        let banner_height = 1;
560        let app_area = Rect {
561            height: area.height.saturating_sub(banner_height),
562            ..area
563        };
564        let banner_area = Rect {
565            y: area.y.saturating_add(app_area.height),
566            height: banner_height.min(area.height),
567            ..area
568        };
569        (app_area, banner_area)
570    }
571
572    fn render_debug_overlay(&self, frame: &mut Frame, app_area: Rect, banner_area: Rect) {
573        // Only dim when there's an overlay open
574        if let Some(ref overlay) = self.freeze.overlay {
575            dim_buffer(frame.buffer_mut(), self.style.dim_factor);
576
577            match overlay {
578                DebugOverlay::Inspect(table) | DebugOverlay::State(table) => {
579                    self.render_table_modal(frame, app_area, table);
580                }
581                DebugOverlay::ActionLog(log) => {
582                    self.render_action_log_modal(frame, app_area, log);
583                }
584                DebugOverlay::ActionDetail(detail) => {
585                    self.render_action_detail_modal(frame, app_area, detail);
586                }
587            }
588        }
589
590        // Render banner
591        self.render_banner(frame, banner_area);
592    }
593
594    fn render_banner(&self, frame: &mut Frame, banner_area: Rect) {
595        if banner_area.height == 0 {
596            return;
597        }
598
599        let keys = &self.style.key_styles;
600        let toggle_key_str = format_key(self.toggle_key);
601        let mut banner = DebugBanner::new()
602            .title("DEBUG")
603            .title_style(self.style.title_style)
604            .label_style(self.style.label_style)
605            .background(self.style.banner_bg);
606
607        // Add standard debug commands with hardcoded keys
608        banner = banner.item(BannerItem::new(&toggle_key_str, "resume", keys.toggle));
609        banner = banner.item(BannerItem::new("a", "actions", keys.actions));
610        banner = banner.item(BannerItem::new("s", "state", keys.state));
611        banner = banner.item(BannerItem::new("y", "copy", keys.copy));
612
613        if self.freeze.mouse_capture_enabled {
614            banner = banner.item(BannerItem::new("click", "inspect", keys.mouse));
615        } else {
616            banner = banner.item(BannerItem::new("i", "mouse", keys.mouse));
617        }
618
619        // Add message if present
620        if let Some(ref msg) = self.freeze.message {
621            banner = banner.item(BannerItem::new("", msg, self.style.value_style));
622        }
623
624        frame.render_widget(banner, banner_area);
625    }
626
627    fn render_table_modal(&self, frame: &mut Frame, app_area: Rect, table: &DebugTableOverlay) {
628        let modal_width = (app_area.width * 80 / 100)
629            .clamp(30, 120)
630            .min(app_area.width);
631        let modal_height = (app_area.height * 60 / 100)
632            .clamp(8, 40)
633            .min(app_area.height);
634
635        let modal_x = app_area.x + (app_area.width.saturating_sub(modal_width)) / 2;
636        let modal_y = app_area.y + (app_area.height.saturating_sub(modal_height)) / 2;
637
638        let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
639
640        frame.render_widget(Clear, modal_area);
641
642        let block = Block::default()
643            .borders(Borders::ALL)
644            .title(format!(" {} ", table.title))
645            .style(self.style.banner_bg);
646
647        let inner = block.inner(modal_area);
648        frame.render_widget(block, modal_area);
649
650        // Cell preview on top if present
651        if let Some(ref preview) = table.cell_preview {
652            if inner.height > 3 {
653                let preview_height = 2u16;
654                let preview_area = Rect {
655                    x: inner.x,
656                    y: inner.y,
657                    width: inner.width,
658                    height: 1,
659                };
660                let table_area = Rect {
661                    x: inner.x,
662                    y: inner.y.saturating_add(preview_height),
663                    width: inner.width,
664                    height: inner.height.saturating_sub(preview_height),
665                };
666
667                let preview_widget = CellPreviewWidget::new(preview)
668                    .label_style(Style::default().fg(DebugStyle::text_secondary()))
669                    .value_style(Style::default().fg(DebugStyle::text_primary()));
670                frame.render_widget(preview_widget, preview_area);
671
672                let table_widget = DebugTableWidget::new(table);
673                frame.render_widget(table_widget, table_area);
674                return;
675            }
676        }
677
678        let table_widget = DebugTableWidget::new(table);
679        frame.render_widget(table_widget, inner);
680    }
681
682    fn render_action_log_modal(&self, frame: &mut Frame, app_area: Rect, log: &ActionLogOverlay) {
683        let modal_width = (app_area.width * 90 / 100)
684            .clamp(40, 140)
685            .min(app_area.width);
686        let modal_height = (app_area.height * 70 / 100)
687            .clamp(10, 50)
688            .min(app_area.height);
689
690        let modal_x = app_area.x + (app_area.width.saturating_sub(modal_width)) / 2;
691        let modal_y = app_area.y + (app_area.height.saturating_sub(modal_height)) / 2;
692
693        let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
694
695        frame.render_widget(Clear, modal_area);
696
697        let entry_count = log.entries.len();
698        let title = if entry_count > 0 {
699            format!(" {} ({} entries) ", log.title, entry_count)
700        } else {
701            format!(" {} (empty) ", log.title)
702        };
703
704        let block = Block::default()
705            .borders(Borders::ALL)
706            .title(title)
707            .style(self.style.banner_bg);
708
709        let inner = block.inner(modal_area);
710        frame.render_widget(block, modal_area);
711
712        let widget = ActionLogWidget::new(log);
713        frame.render_widget(widget, inner);
714    }
715
716    fn render_action_detail_modal(
717        &self,
718        frame: &mut Frame,
719        app_area: Rect,
720        detail: &super::table::ActionDetailOverlay,
721    ) {
722        let modal_width = (app_area.width * 80 / 100)
723            .clamp(40, 120)
724            .min(app_area.width);
725        let modal_height = (app_area.height * 50 / 100)
726            .clamp(8, 30)
727            .min(app_area.height);
728
729        let modal_x = app_area.x + (app_area.width.saturating_sub(modal_width)) / 2;
730        let modal_y = app_area.y + (app_area.height.saturating_sub(modal_height)) / 2;
731
732        let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
733
734        frame.render_widget(Clear, modal_area);
735
736        let title = format!(" Action #{} - {} ", detail.sequence, detail.name);
737
738        let block = Block::default()
739            .borders(Borders::ALL)
740            .title(title)
741            .style(self.style.banner_bg);
742
743        let inner = block.inner(modal_area);
744        frame.render_widget(block, modal_area);
745
746        // Build detail content
747        use ratatui::text::{Line, Span};
748        use ratatui::widgets::Paragraph;
749
750        let label_style = Style::default().fg(DebugStyle::text_secondary());
751        let value_style = Style::default().fg(DebugStyle::text_primary());
752
753        let mut lines = vec![
754            // Name
755            Line::from(vec![
756                Span::styled("Name: ", label_style),
757                Span::styled(&detail.name, value_style),
758            ]),
759            // Sequence
760            Line::from(vec![
761                Span::styled("Sequence: ", label_style),
762                Span::styled(detail.sequence.to_string(), value_style),
763            ]),
764            // Elapsed
765            Line::from(vec![
766                Span::styled("Elapsed: ", label_style),
767                Span::styled(&detail.elapsed, value_style),
768            ]),
769            // Empty line before params
770            Line::from(""),
771            // Parameters header
772            Line::from(Span::styled("Parameters:", label_style)),
773        ];
774
775        // Parameters content (potentially multi-line)
776        if detail.params.is_empty() {
777            lines.push(Line::from(Span::styled("  (none)", value_style)));
778        } else {
779            for param_line in detail.params.lines() {
780                lines.push(Line::from(Span::styled(
781                    format!("  {}", param_line),
782                    value_style,
783                )));
784            }
785        }
786
787        // Footer hint
788        lines.push(Line::from(""));
789        lines.push(Line::from(Span::styled(
790            "Press Enter/Esc/Backspace to go back",
791            label_style,
792        )));
793
794        let paragraph = Paragraph::new(lines);
795        frame.render_widget(paragraph, inner);
796    }
797
798    fn build_inspect_overlay(&self, column: u16, row: u16, snapshot: &Buffer) -> DebugTableOverlay {
799        let mut builder = DebugTableBuilder::new();
800
801        builder.push_section("Position");
802        builder.push_entry("column", column.to_string());
803        builder.push_entry("row", row.to_string());
804
805        if let Some(preview) = inspect_cell(snapshot, column, row) {
806            builder.set_cell_preview(preview);
807        }
808
809        builder.finish(format!("Inspect ({column}, {row})"))
810    }
811}
812
813/// Format a KeyCode for display in the banner.
814fn format_key(key: KeyCode) -> String {
815    match key {
816        KeyCode::F(n) => format!("F{}", n),
817        KeyCode::Char(c) => c.to_string(),
818        KeyCode::Esc => "Esc".to_string(),
819        KeyCode::Enter => "Enter".to_string(),
820        KeyCode::Tab => "Tab".to_string(),
821        KeyCode::Backspace => "Bksp".to_string(),
822        KeyCode::Delete => "Del".to_string(),
823        KeyCode::Up => "↑".to_string(),
824        KeyCode::Down => "↓".to_string(),
825        KeyCode::Left => "←".to_string(),
826        KeyCode::Right => "→".to_string(),
827        _ => format!("{:?}", key),
828    }
829}
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834
835    #[derive(Debug, Clone)]
836    enum TestAction {
837        Foo,
838        Bar,
839    }
840
841    impl crate::Action for TestAction {
842        fn name(&self) -> &'static str {
843            match self {
844                TestAction::Foo => "Foo",
845                TestAction::Bar => "Bar",
846            }
847        }
848    }
849
850    impl crate::ActionParams for TestAction {
851        fn params(&self) -> String {
852            String::new()
853        }
854    }
855
856    #[test]
857    fn test_debug_layer_creation() {
858        let layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
859        assert!(!layer.is_enabled());
860        assert!(layer.freeze().snapshot.is_none());
861    }
862
863    #[test]
864    fn test_toggle() {
865        let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
866
867        // Enable
868        let effect = layer.toggle();
869        assert!(effect.is_none());
870        assert!(layer.is_enabled());
871
872        // Disable
873        let effect = layer.toggle();
874        assert!(effect.is_none()); // No queued actions
875        assert!(!layer.is_enabled());
876    }
877
878    #[test]
879    fn test_queued_actions_returned_on_disable() {
880        let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
881
882        layer.toggle(); // Enable
883        layer.queue_action(TestAction::Foo);
884        layer.queue_action(TestAction::Bar);
885
886        let effect = layer.toggle(); // Disable
887
888        match effect {
889            Some(DebugSideEffect::ProcessQueuedActions(actions)) => {
890                assert_eq!(actions.len(), 2);
891            }
892            _ => panic!("Expected ProcessQueuedActions"),
893        }
894    }
895
896    #[test]
897    fn test_split_area() {
898        let layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
899
900        // Disabled: full area returned
901        let area = Rect::new(0, 0, 80, 24);
902        let (app, banner) = layer.split_area(area);
903        assert_eq!(app, area);
904        assert_eq!(banner, Rect::ZERO);
905    }
906
907    #[test]
908    fn test_split_area_enabled() {
909        let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
910        layer.toggle();
911
912        let area = Rect::new(0, 0, 80, 24);
913        let (app, banner) = layer.split_area(area);
914
915        assert_eq!(app.height, 23);
916        assert_eq!(banner.height, 1);
917        assert_eq!(banner.y, 23);
918    }
919
920    #[test]
921    fn test_inactive_layer() {
922        let layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12)).active(false);
923
924        assert!(!layer.is_active());
925        assert!(!layer.is_enabled());
926    }
927
928    #[test]
929    fn test_action_log() {
930        let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
931
932        layer.log_action(&TestAction::Foo);
933        layer.log_action(&TestAction::Bar);
934
935        assert_eq!(layer.action_log().entries().count(), 2);
936    }
937}