1use 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, Scrollbar, ScrollbarOrientation, ScrollbarState};
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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum BannerPosition {
36 Bottom,
37 Top,
38}
39
40impl BannerPosition {
41 pub fn toggle(self) -> Self {
43 match self {
44 Self::Bottom => Self::Top,
45 Self::Top => Self::Bottom,
46 }
47 }
48
49 fn label(self) -> &'static str {
50 match self {
51 Self::Bottom => "bar:bottom",
52 Self::Top => "bar:top",
53 }
54 }
55}
56
57pub struct DebugOutcome<A> {
59 pub consumed: bool,
61 pub queued_actions: Vec<A>,
63 pub needs_render: bool,
65}
66
67impl<A> DebugOutcome<A> {
68 fn ignored() -> Self {
69 Self {
70 consumed: false,
71 queued_actions: Vec::new(),
72 needs_render: false,
73 }
74 }
75
76 fn consumed(queued_actions: Vec<A>) -> Self {
77 Self {
78 consumed: true,
79 queued_actions,
80 needs_render: true,
81 }
82 }
83
84 pub fn dispatch_queued<F>(self, mut dispatch: F) -> Option<bool>
88 where
89 F: FnMut(A),
90 {
91 if !self.consumed {
92 return None;
93 }
94
95 for action in self.queued_actions {
96 dispatch(action);
97 }
98
99 Some(self.needs_render)
100 }
101}
102
103impl<A> Default for DebugOutcome<A> {
104 fn default() -> Self {
105 Self::ignored()
106 }
107}
108
109pub struct DebugLayer<A> {
138 toggle_key: KeyCode,
140 freeze: DebugFreeze<A>,
142 banner_position: BannerPosition,
144 style: DebugStyle,
146 active: bool,
148 action_log: ActionLog,
150 state_snapshot: Option<DebugTableOverlay>,
152 table_scroll_offset: usize,
154 table_page_size: usize,
156 #[cfg(feature = "tasks")]
158 task_handle: Option<TaskPauseHandle<A>>,
159 #[cfg(feature = "subscriptions")]
161 sub_handle: Option<SubPauseHandle>,
162}
163
164impl<A> std::fmt::Debug for DebugLayer<A> {
165 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 f.debug_struct("DebugLayer")
167 .field("toggle_key", &self.toggle_key)
168 .field("active", &self.active)
169 .field("enabled", &self.freeze.enabled)
170 .field("has_snapshot", &self.freeze.snapshot.is_some())
171 .field("has_state_snapshot", &self.state_snapshot.is_some())
172 .field("banner_position", &self.banner_position)
173 .field("table_scroll_offset", &self.table_scroll_offset)
174 .field("queued_actions", &self.freeze.queued_actions.len())
175 .finish()
176 }
177}
178
179impl<A: Action> DebugLayer<A> {
180 pub fn new(toggle_key: KeyCode) -> Self {
190 Self {
191 toggle_key,
192 freeze: DebugFreeze::new(),
193 banner_position: BannerPosition::Bottom,
194 style: DebugStyle::default(),
195 active: true,
196 action_log: ActionLog::new(ActionLogConfig::with_capacity(100)),
197 state_snapshot: None,
198 table_scroll_offset: 0,
199 table_page_size: 1,
200 #[cfg(feature = "tasks")]
201 task_handle: None,
202 #[cfg(feature = "subscriptions")]
203 sub_handle: None,
204 }
205 }
206
207 pub fn simple() -> Self {
209 Self::new(KeyCode::F(12))
210 }
211
212 pub fn simple_with_toggle_key(toggle_key: KeyCode) -> Self {
214 Self::new(toggle_key)
215 }
216
217 pub fn active(mut self, active: bool) -> Self {
221 self.active = active;
222 self
223 }
224
225 pub fn with_banner_position(mut self, position: BannerPosition) -> Self {
227 self.banner_position = position;
228 self
229 }
230
231 #[cfg(feature = "tasks")]
236 pub fn with_task_manager(mut self, tasks: &crate::tasks::TaskManager<A>) -> Self {
237 self.task_handle = Some(tasks.pause_handle());
238 self
239 }
240
241 #[cfg(feature = "subscriptions")]
245 pub fn with_subscriptions(mut self, subs: &crate::subscriptions::Subscriptions<A>) -> Self {
246 self.sub_handle = Some(subs.pause_handle());
247 self
248 }
249
250 pub fn with_action_log_capacity(mut self, capacity: usize) -> Self {
252 self.action_log = ActionLog::new(ActionLogConfig::with_capacity(capacity));
253 self
254 }
255
256 pub fn with_style(mut self, style: DebugStyle) -> Self {
258 self.style = style;
259 self
260 }
261
262 pub fn is_active(&self) -> bool {
264 self.active
265 }
266
267 pub fn is_enabled(&self) -> bool {
269 self.active && self.freeze.enabled
270 }
271
272 pub fn toggle_enabled(&mut self) -> Option<DebugSideEffect<A>> {
276 if !self.active {
277 return None;
278 }
279 self.toggle()
280 }
281
282 pub fn set_enabled(&mut self, enabled: bool) -> Option<DebugSideEffect<A>> {
286 if !self.active || enabled == self.freeze.enabled {
287 return None;
288 }
289 self.toggle()
290 }
291
292 pub fn set_banner_position(&mut self, position: BannerPosition) {
294 if self.banner_position != position {
295 self.banner_position = position;
296 if self.freeze.enabled {
297 self.freeze.request_capture();
298 }
299 }
300 }
301
302 pub fn is_state_overlay_visible(&self) -> bool {
304 matches!(self.freeze.overlay, Some(DebugOverlay::State(_)))
305 }
306
307 pub fn freeze(&self) -> &DebugFreeze<A> {
309 &self.freeze
310 }
311
312 pub fn freeze_mut(&mut self) -> &mut DebugFreeze<A> {
314 &mut self.freeze
315 }
316
317 pub fn log_action<T: crate::ActionParams>(&mut self, action: &T) {
321 if self.active {
322 self.action_log.log(action);
323 }
324 }
325
326 pub fn action_log(&self) -> &ActionLog {
328 &self.action_log
329 }
330
331 pub fn render<F>(&mut self, frame: &mut Frame, render_fn: F)
336 where
337 F: FnOnce(&mut Frame, Rect),
338 {
339 self.render_with_state(frame, |frame, area, _wants_state| {
340 render_fn(frame, area);
341 None
342 });
343 }
344
345 pub fn render_with_state<F>(&mut self, frame: &mut Frame, render_fn: F)
351 where
352 F: FnOnce(&mut Frame, Rect, bool) -> Option<DebugTableOverlay>,
353 {
354 let screen = frame.area();
355
356 if !self.active || !self.freeze.enabled {
358 let _ = render_fn(frame, screen, false);
359 return;
360 }
361
362 let (app_area, banner_area) = self.split_for_banner(screen);
364
365 if self.freeze.pending_capture || self.freeze.snapshot.is_none() {
366 let state_snapshot = render_fn(frame, app_area, true);
368 self.state_snapshot = state_snapshot;
369 if let Some(ref table) = self.state_snapshot {
370 if self.is_state_overlay_visible() {
371 self.set_state_overlay(table.clone());
372 }
373 }
374 let buffer_clone = frame.buffer_mut().clone();
375 self.freeze.capture(&buffer_clone);
376 } else if let Some(ref snapshot) = self.freeze.snapshot {
377 paint_snapshot(frame, snapshot);
379 }
380
381 self.render_debug_overlay(frame, app_area, banner_area);
383 }
384
385 pub fn render_state<S: DebugState, F>(&mut self, frame: &mut Frame, state: &S, render_fn: F)
389 where
390 F: FnOnce(&mut Frame, Rect),
391 {
392 self.render_with_state(frame, |frame, area, wants_state| {
393 render_fn(frame, area);
394 if wants_state {
395 Some(state.build_debug_table("Application State"))
396 } else {
397 None
398 }
399 });
400 }
401
402 pub fn split_area(&self, area: Rect) -> (Rect, Rect) {
404 if !self.freeze.enabled {
405 return (area, Rect::ZERO);
406 }
407 self.split_for_banner(area)
408 }
409
410 pub fn intercepts(&mut self, event: &crate::EventKind) -> bool {
424 self.intercepts_with_effects(event).is_some()
425 }
426
427 pub fn handle_event(&mut self, event: &crate::EventKind) -> DebugOutcome<A> {
429 self.handle_event_internal::<()>(event, None)
430 }
431
432 pub fn handle_event_with_state<S: DebugState>(
434 &mut self,
435 event: &crate::EventKind,
436 state: &S,
437 ) -> DebugOutcome<A> {
438 self.handle_event_internal(event, Some(state))
439 }
440
441 pub fn intercepts_with_effects(
445 &mut self,
446 event: &crate::EventKind,
447 ) -> Option<Vec<DebugSideEffect<A>>> {
448 self.intercepts_with_effects_internal::<()>(event, None)
449 }
450
451 pub fn intercepts_with_effects_and_state<S: DebugState>(
455 &mut self,
456 event: &crate::EventKind,
457 state: &S,
458 ) -> Option<Vec<DebugSideEffect<A>>> {
459 self.intercepts_with_effects_internal(event, Some(state))
460 }
461
462 pub fn intercepts_with_state<S: DebugState>(
464 &mut self,
465 event: &crate::EventKind,
466 state: &S,
467 ) -> bool {
468 self.intercepts_with_effects_internal(event, Some(state))
469 .is_some()
470 }
471
472 fn handle_event_internal<S: DebugState>(
473 &mut self,
474 event: &crate::EventKind,
475 state: Option<&S>,
476 ) -> DebugOutcome<A> {
477 let effects = self.intercepts_with_effects_internal(event, state);
478 let Some(effects) = effects else {
479 return DebugOutcome::ignored();
480 };
481
482 let mut queued_actions = Vec::new();
483 for effect in effects {
484 if let DebugSideEffect::ProcessQueuedActions(actions) = effect {
485 queued_actions.extend(actions);
486 }
487 }
488
489 DebugOutcome::consumed(queued_actions)
490 }
491
492 fn intercepts_with_effects_internal<S: DebugState>(
493 &mut self,
494 event: &crate::EventKind,
495 state: Option<&S>,
496 ) -> Option<Vec<DebugSideEffect<A>>> {
497 if !self.active {
498 return None;
499 }
500
501 use crate::EventKind;
502
503 match event {
504 EventKind::Key(key) => self.handle_key_event(*key, state),
505 EventKind::Mouse(mouse) => {
506 if !self.freeze.enabled {
507 return None;
508 }
509
510 if !self.freeze.mouse_capture_enabled {
513 return None;
514 }
515
516 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
518 let effect = self.handle_action(DebugAction::InspectCell {
519 column: mouse.column,
520 row: mouse.row,
521 });
522 return Some(effect.into_iter().collect());
523 }
524
525 Some(vec![])
527 }
528 EventKind::Scroll { delta, .. } => {
529 if !self.freeze.enabled {
530 return None;
531 }
532
533 match self.freeze.overlay.as_ref() {
534 Some(DebugOverlay::ActionLog(_)) => {
535 let action = if *delta > 0 {
536 DebugAction::ActionLogScrollUp
537 } else {
538 DebugAction::ActionLogScrollDown
539 };
540 self.handle_action(action);
541 }
542 Some(DebugOverlay::State(table)) | Some(DebugOverlay::Inspect(table)) => {
543 if *delta > 0 {
544 self.scroll_table_up();
545 } else {
546 self.scroll_table_down(table.rows.len());
547 }
548 }
549 _ => {}
550 }
551
552 Some(vec![])
553 }
554 EventKind::Resize(_, _) | EventKind::Tick => None,
556 }
557 }
558
559 pub fn show_state_overlay<S: DebugState>(&mut self, state: &S) {
561 let table = state.build_debug_table("Application State");
562 self.set_state_overlay(table);
563 }
564
565 pub fn show_action_log(&mut self) {
567 let overlay = ActionLogOverlay::from_log(&self.action_log, "Action Log");
568 self.freeze.set_overlay(DebugOverlay::ActionLog(overlay));
569 }
570
571 pub fn queue_action(&mut self, action: A) {
573 self.freeze.queue(action);
574 }
575
576 pub fn take_queued_actions(&mut self) -> Vec<A> {
581 std::mem::take(&mut self.freeze.queued_actions)
582 }
583
584 fn set_state_overlay(&mut self, table: DebugTableOverlay) {
589 if !matches!(self.freeze.overlay, Some(DebugOverlay::State(_))) {
590 self.table_scroll_offset = 0;
591 }
592 self.state_snapshot = Some(table.clone());
593 self.freeze.set_overlay(DebugOverlay::State(table));
594 }
595
596 fn update_table_scroll(&mut self, table: &DebugTableOverlay, table_area: Rect) {
597 let visible_rows = table_area.height.saturating_sub(1) as usize;
598 self.table_page_size = visible_rows.max(1);
599 let max_offset = table.rows.len().saturating_sub(visible_rows);
600 self.table_scroll_offset = self.table_scroll_offset.min(max_offset);
601 }
602
603 fn build_scrollbar(&self, orientation: ScrollbarOrientation) -> Scrollbar<'static> {
604 let mut scrollbar = Scrollbar::new(orientation)
605 .thumb_style(self.style.scrollbar.thumb)
606 .track_style(self.style.scrollbar.track)
607 .begin_style(self.style.scrollbar.begin)
608 .end_style(self.style.scrollbar.end);
609
610 if let Some(symbol) = self.style.scrollbar.thumb_symbol {
611 scrollbar = scrollbar.thumb_symbol(symbol);
612 }
613 if let Some(symbol) = self.style.scrollbar.track_symbol {
614 scrollbar = scrollbar.track_symbol(Some(symbol));
615 }
616 if let Some(symbol) = self.style.scrollbar.begin_symbol {
617 scrollbar = scrollbar.begin_symbol(Some(symbol));
618 }
619 if let Some(symbol) = self.style.scrollbar.end_symbol {
620 scrollbar = scrollbar.end_symbol(Some(symbol));
621 }
622
623 scrollbar
624 }
625
626 fn table_page_size_value(&self) -> usize {
627 self.table_page_size.max(1)
628 }
629
630 fn table_max_offset(&self, rows_len: usize) -> usize {
631 rows_len.saturating_sub(self.table_page_size_value())
632 }
633
634 fn scroll_table_up(&mut self) {
635 self.table_scroll_offset = self.table_scroll_offset.saturating_sub(1);
636 }
637
638 fn scroll_table_down(&mut self, rows_len: usize) {
639 let max_offset = self.table_max_offset(rows_len);
640 self.table_scroll_offset = (self.table_scroll_offset + 1).min(max_offset);
641 }
642
643 fn scroll_table_to_top(&mut self) {
644 self.table_scroll_offset = 0;
645 }
646
647 fn scroll_table_to_bottom(&mut self, rows_len: usize) {
648 self.table_scroll_offset = self.table_max_offset(rows_len);
649 }
650
651 fn scroll_table_page_up(&mut self) {
652 let page_size = self.table_page_size_value();
653 self.table_scroll_offset = self.table_scroll_offset.saturating_sub(page_size);
654 }
655
656 fn scroll_table_page_down(&mut self, rows_len: usize) {
657 let page_size = self.table_page_size_value();
658 let max_offset = self.table_max_offset(rows_len);
659 self.table_scroll_offset = (self.table_scroll_offset + page_size).min(max_offset);
660 }
661
662 fn handle_table_scroll_key(&mut self, key: KeyCode, rows_len: usize) -> bool {
663 match key {
664 KeyCode::Char('j') | KeyCode::Down => {
665 self.scroll_table_down(rows_len);
666 true
667 }
668 KeyCode::Char('k') | KeyCode::Up => {
669 self.scroll_table_up();
670 true
671 }
672 KeyCode::Char('g') => {
673 self.scroll_table_to_top();
674 true
675 }
676 KeyCode::Char('G') => {
677 self.scroll_table_to_bottom(rows_len);
678 true
679 }
680 KeyCode::PageDown => {
681 self.scroll_table_page_down(rows_len);
682 true
683 }
684 KeyCode::PageUp => {
685 self.scroll_table_page_up();
686 true
687 }
688 _ => false,
689 }
690 }
691
692 fn handle_key_event<S: DebugState>(
693 &mut self,
694 key: KeyEvent,
695 state: Option<&S>,
696 ) -> Option<Vec<DebugSideEffect<A>>> {
697 if key.code == self.toggle_key && key.modifiers.is_empty() {
699 let effect = self.toggle();
700 return Some(effect.into_iter().collect());
701 }
702
703 if self.freeze.enabled && key.code == KeyCode::Esc {
705 let effect = self.toggle();
706 return Some(effect.into_iter().collect());
707 }
708
709 if !self.freeze.enabled {
711 return None;
712 }
713
714 match key.code {
715 KeyCode::Char('b') | KeyCode::Char('B') => {
716 self.banner_position = self.banner_position.toggle();
717 self.freeze.request_capture();
718 return Some(vec![]);
719 }
720 KeyCode::Char('s') | KeyCode::Char('S') => {
721 if matches!(self.freeze.overlay, Some(DebugOverlay::State(_))) {
722 self.freeze.clear_overlay();
723 } else if let Some(state) = state {
724 let table = state.build_debug_table("Application State");
725 self.set_state_overlay(table);
726 } else if let Some(ref table) = self.state_snapshot {
727 self.set_state_overlay(table.clone());
728 } else {
729 let table = DebugTableBuilder::new()
730 .section("State")
731 .entry(
732 "hint",
733 "Press 's' after providing state via render_with_state() or show_state_overlay()",
734 )
735 .finish("Application State");
736 self.freeze.set_overlay(DebugOverlay::State(table));
737 }
738 return Some(vec![]);
739 }
740 _ => {}
741 }
742
743 let action = match key.code {
745 KeyCode::Char('a') | KeyCode::Char('A') => Some(DebugAction::ToggleActionLog),
746 KeyCode::Char('y') | KeyCode::Char('Y') => Some(DebugAction::CopyFrame),
747 KeyCode::Char('i') | KeyCode::Char('I') => Some(DebugAction::ToggleMouseCapture),
748 KeyCode::Char('q') | KeyCode::Char('Q') => Some(DebugAction::CloseOverlay),
749 _ => None,
750 };
751
752 if let Some(action) = action {
753 let effect = self.handle_action(action);
754 return Some(effect.into_iter().collect());
755 }
756
757 match &self.freeze.overlay {
759 Some(DebugOverlay::ActionLog(_)) => {
760 let action = match key.code {
761 KeyCode::Char('j') | KeyCode::Down => Some(DebugAction::ActionLogScrollDown),
762 KeyCode::Char('k') | KeyCode::Up => Some(DebugAction::ActionLogScrollUp),
763 KeyCode::Char('g') => Some(DebugAction::ActionLogScrollTop),
764 KeyCode::Char('G') => Some(DebugAction::ActionLogScrollBottom),
765 KeyCode::PageDown => Some(DebugAction::ActionLogPageDown),
766 KeyCode::PageUp => Some(DebugAction::ActionLogPageUp),
767 KeyCode::Enter => Some(DebugAction::ActionLogShowDetail),
768 _ => None,
769 };
770 if let Some(action) = action {
771 self.handle_action(action);
772 return Some(vec![]);
773 }
774 }
775 Some(DebugOverlay::State(table)) | Some(DebugOverlay::Inspect(table)) => {
776 if self.handle_table_scroll_key(key.code, table.rows.len()) {
777 return Some(vec![]);
778 }
779 }
780 Some(DebugOverlay::ActionDetail(_)) => {
781 if matches!(key.code, KeyCode::Esc | KeyCode::Backspace | KeyCode::Enter) {
783 self.handle_action(DebugAction::ActionLogBackToList);
784 return Some(vec![]);
785 }
786 }
787 _ => {}
788 }
789
790 Some(vec![])
792 }
793
794 fn toggle(&mut self) -> Option<DebugSideEffect<A>> {
795 if self.freeze.enabled {
796 #[cfg(feature = "subscriptions")]
798 if let Some(ref handle) = self.sub_handle {
799 handle.resume();
800 }
801
802 #[cfg(feature = "tasks")]
803 let task_queued = if let Some(ref handle) = self.task_handle {
804 handle.resume()
805 } else {
806 vec![]
807 };
808 #[cfg(not(feature = "tasks"))]
809 let task_queued: Vec<A> = vec![];
810
811 let queued = self.freeze.take_queued();
812 self.freeze.disable();
813 self.state_snapshot = None;
814 self.table_scroll_offset = 0;
815 self.table_page_size = 1;
816
817 let mut all_queued = queued;
819 all_queued.extend(task_queued);
820
821 if all_queued.is_empty() {
822 None
823 } else {
824 Some(DebugSideEffect::ProcessQueuedActions(all_queued))
825 }
826 } else {
827 #[cfg(feature = "tasks")]
829 if let Some(ref handle) = self.task_handle {
830 handle.pause();
831 }
832 #[cfg(feature = "subscriptions")]
833 if let Some(ref handle) = self.sub_handle {
834 handle.pause();
835 }
836 self.freeze.enable();
837 self.state_snapshot = None;
838 self.table_scroll_offset = 0;
839 self.table_page_size = 1;
840 None
841 }
842 }
843
844 fn handle_action(&mut self, action: DebugAction) -> Option<DebugSideEffect<A>> {
845 match action {
846 DebugAction::Toggle => self.toggle(),
847 DebugAction::CopyFrame => {
848 let text = &self.freeze.snapshot_text;
849 let encoded = BASE64_STANDARD.encode(text);
851 print!("\x1b]52;c;{}\x07", encoded);
852 std::io::stdout().flush().ok();
853 self.freeze.set_message("Copied to clipboard");
854 None
855 }
856 DebugAction::ToggleState => {
857 if matches!(self.freeze.overlay, Some(DebugOverlay::State(_))) {
858 self.freeze.clear_overlay();
859 } else if let Some(ref table) = self.state_snapshot {
860 self.set_state_overlay(table.clone());
861 } else {
862 let table = DebugTableBuilder::new()
864 .section("State")
865 .entry(
866 "hint",
867 "Press 's' after providing state via render_with_state() or show_state_overlay()",
868 )
869 .finish("Application State");
870 self.freeze.set_overlay(DebugOverlay::State(table));
871 }
872 None
873 }
874 DebugAction::ToggleActionLog => {
875 if matches!(self.freeze.overlay, Some(DebugOverlay::ActionLog(_))) {
876 self.freeze.clear_overlay();
877 } else {
878 self.show_action_log();
879 }
880 None
881 }
882 DebugAction::ActionLogScrollUp => {
883 if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
884 log.scroll_up();
885 }
886 None
887 }
888 DebugAction::ActionLogScrollDown => {
889 if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
890 log.scroll_down();
891 }
892 None
893 }
894 DebugAction::ActionLogScrollTop => {
895 if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
896 log.scroll_to_top();
897 }
898 None
899 }
900 DebugAction::ActionLogScrollBottom => {
901 if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
902 log.scroll_to_bottom();
903 }
904 None
905 }
906 DebugAction::ActionLogPageUp => {
907 if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
908 log.page_up(10);
909 }
910 None
911 }
912 DebugAction::ActionLogPageDown => {
913 if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
914 log.page_down(10);
915 }
916 None
917 }
918 DebugAction::ActionLogShowDetail => {
919 if let Some(DebugOverlay::ActionLog(ref log)) = self.freeze.overlay {
920 if let Some(detail) = log.selected_detail() {
921 self.freeze.set_overlay(DebugOverlay::ActionDetail(detail));
922 }
923 }
924 None
925 }
926 DebugAction::ActionLogBackToList => {
927 if matches!(self.freeze.overlay, Some(DebugOverlay::ActionDetail(_))) {
929 self.show_action_log();
930 }
931 None
932 }
933 DebugAction::ToggleMouseCapture => {
934 self.freeze.toggle_mouse_capture();
935 None
936 }
937 DebugAction::InspectCell { column, row } => {
938 if let Some(ref snapshot) = self.freeze.snapshot {
939 let overlay = self.build_inspect_overlay(column, row, snapshot);
940 self.table_scroll_offset = 0;
941 self.freeze.set_overlay(DebugOverlay::Inspect(overlay));
942 }
943 self.freeze.mouse_capture_enabled = false;
944 None
945 }
946 DebugAction::CloseOverlay => {
947 self.freeze.clear_overlay();
948 None
949 }
950 DebugAction::RequestCapture => {
951 self.freeze.request_capture();
952 None
953 }
954 }
955 }
956
957 fn split_for_banner(&self, area: Rect) -> (Rect, Rect) {
958 let banner_height = area.height.min(1);
959 match self.banner_position {
960 BannerPosition::Bottom => {
961 let app_area = Rect {
962 height: area.height.saturating_sub(banner_height),
963 ..area
964 };
965 let banner_area = Rect {
966 y: area.y.saturating_add(app_area.height),
967 height: banner_height,
968 ..area
969 };
970 (app_area, banner_area)
971 }
972 BannerPosition::Top => {
973 let banner_area = Rect {
974 y: area.y,
975 height: banner_height,
976 ..area
977 };
978 let app_area = Rect {
979 y: area.y.saturating_add(banner_height),
980 height: area.height.saturating_sub(banner_height),
981 ..area
982 };
983 (app_area, banner_area)
984 }
985 }
986 }
987
988 fn render_debug_overlay(&mut self, frame: &mut Frame, app_area: Rect, banner_area: Rect) {
989 let overlay = self.freeze.overlay.clone();
990
991 if let Some(ref overlay) = overlay {
993 dim_buffer(frame.buffer_mut(), self.style.dim_factor);
994
995 match overlay {
996 DebugOverlay::Inspect(table) | DebugOverlay::State(table) => {
997 self.render_table_modal(frame, app_area, table);
998 }
999 DebugOverlay::ActionLog(log) => {
1000 self.render_action_log_modal(frame, app_area, log);
1001 }
1002 DebugOverlay::ActionDetail(detail) => {
1003 self.render_action_detail_modal(frame, app_area, detail);
1004 }
1005 }
1006 }
1007
1008 self.render_banner(frame, banner_area);
1010 }
1011
1012 fn render_banner(&self, frame: &mut Frame, banner_area: Rect) {
1013 if banner_area.height == 0 {
1014 return;
1015 }
1016
1017 let keys = &self.style.key_styles;
1018 let toggle_key_str = format_key(self.toggle_key);
1019 let mut banner = DebugBanner::new()
1020 .title("DEBUG")
1021 .title_style(self.style.title_style)
1022 .label_style(self.style.label_style)
1023 .background(self.style.banner_bg);
1024
1025 banner = banner.item(BannerItem::new(&toggle_key_str, "resume", keys.toggle));
1027 banner = banner.item(BannerItem::new("a", "actions", keys.actions));
1028 banner = banner.item(BannerItem::new("s", "state", keys.state));
1029 banner = banner.item(BannerItem::new(
1030 "b",
1031 self.banner_position.label(),
1032 keys.actions,
1033 ));
1034 banner = banner.item(BannerItem::new("y", "copy", keys.copy));
1035
1036 if self.freeze.mouse_capture_enabled {
1037 banner = banner.item(BannerItem::new("click", "inspect", keys.mouse));
1038 } else {
1039 banner = banner.item(BannerItem::new("i", "mouse", keys.mouse));
1040 }
1041
1042 if let Some(ref msg) = self.freeze.message {
1044 banner = banner.item(BannerItem::new("", msg, self.style.value_style));
1045 }
1046
1047 frame.render_widget(banner, banner_area);
1048 }
1049
1050 fn render_table_modal(&mut self, frame: &mut Frame, app_area: Rect, table: &DebugTableOverlay) {
1051 let modal_width = (app_area.width * 80 / 100)
1052 .clamp(30, 120)
1053 .min(app_area.width);
1054 let modal_height = (app_area.height * 60 / 100)
1055 .clamp(8, 40)
1056 .min(app_area.height);
1057
1058 let modal_x = app_area.x + (app_area.width.saturating_sub(modal_width)) / 2;
1059 let modal_y = app_area.y + (app_area.height.saturating_sub(modal_height)) / 2;
1060
1061 let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
1062
1063 frame.render_widget(Clear, modal_area);
1064
1065 let block = Block::default()
1066 .borders(Borders::ALL)
1067 .title(format!(" {} ", table.title))
1068 .style(self.style.banner_bg);
1069
1070 let inner = block.inner(modal_area);
1071 frame.render_widget(block, modal_area);
1072
1073 let mut table_area = if let Some(ref preview) = table.cell_preview {
1074 if inner.height > 3 {
1075 let preview_height = 2u16;
1076 let preview_area = Rect {
1077 x: inner.x,
1078 y: inner.y,
1079 width: inner.width,
1080 height: 1,
1081 };
1082 let table_area = Rect {
1083 x: inner.x,
1084 y: inner.y.saturating_add(preview_height),
1085 width: inner.width,
1086 height: inner.height.saturating_sub(preview_height),
1087 };
1088
1089 let preview_widget = CellPreviewWidget::new(preview)
1090 .label_style(Style::default().fg(DebugStyle::text_secondary()))
1091 .value_style(Style::default().fg(DebugStyle::text_primary()));
1092 frame.render_widget(preview_widget, preview_area);
1093 table_area
1094 } else {
1095 inner
1096 }
1097 } else {
1098 inner
1099 };
1100
1101 let visible_rows = table_area.height.saturating_sub(1) as usize;
1102 let show_scrollbar =
1103 visible_rows > 0 && table.rows.len() > visible_rows && table_area.width > 11;
1104 let scrollbar_area = if show_scrollbar {
1105 let scrollbar_area = Rect {
1106 x: table_area.x + table_area.width.saturating_sub(1),
1107 width: 1,
1108 ..table_area
1109 };
1110 table_area.width = table_area.width.saturating_sub(1);
1111 Some(Rect {
1112 y: scrollbar_area.y.saturating_add(1),
1113 height: scrollbar_area.height.saturating_sub(1),
1114 ..scrollbar_area
1115 })
1116 } else {
1117 None
1118 };
1119
1120 self.update_table_scroll(table, table_area);
1121 let table_widget = DebugTableWidget::new(table).scroll_offset(self.table_scroll_offset);
1122 frame.render_widget(table_widget, table_area);
1123
1124 if let Some(scrollbar_area) = scrollbar_area {
1125 let content_length = table.rows.len();
1126 let mut scrollbar_state = ScrollbarState::new(content_length)
1127 .position(self.table_scroll_offset)
1128 .viewport_content_length(self.table_page_size_value());
1129 let scrollbar = self.build_scrollbar(ScrollbarOrientation::VerticalRight);
1130 frame.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
1131 }
1132 }
1133
1134 fn render_action_log_modal(&self, frame: &mut Frame, app_area: Rect, log: &ActionLogOverlay) {
1135 let modal_width = (app_area.width * 90 / 100)
1136 .clamp(40, 140)
1137 .min(app_area.width);
1138 let modal_height = (app_area.height * 70 / 100)
1139 .clamp(10, 50)
1140 .min(app_area.height);
1141
1142 let modal_x = app_area.x + (app_area.width.saturating_sub(modal_width)) / 2;
1143 let modal_y = app_area.y + (app_area.height.saturating_sub(modal_height)) / 2;
1144
1145 let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
1146
1147 frame.render_widget(Clear, modal_area);
1148
1149 let entry_count = log.entries.len();
1150 let title = if entry_count > 0 {
1151 format!(" {} ({} entries) ", log.title, entry_count)
1152 } else {
1153 format!(" {} (empty) ", log.title)
1154 };
1155
1156 let block = Block::default()
1157 .borders(Borders::ALL)
1158 .title(title)
1159 .style(self.style.banner_bg);
1160
1161 let mut log_area = block.inner(modal_area);
1162 frame.render_widget(block, modal_area);
1163
1164 let visible_rows = log_area.height.saturating_sub(1) as usize;
1165 let show_scrollbar =
1166 visible_rows > 0 && log.entries.len() > visible_rows && log_area.width > 31;
1167 let scrollbar_area = if show_scrollbar {
1168 let scrollbar_area = Rect {
1169 x: log_area.x + log_area.width.saturating_sub(1),
1170 width: 1,
1171 ..log_area
1172 };
1173 log_area.width = log_area.width.saturating_sub(1);
1174 Some(Rect {
1175 y: scrollbar_area.y.saturating_add(1),
1176 height: scrollbar_area.height.saturating_sub(1),
1177 ..scrollbar_area
1178 })
1179 } else {
1180 None
1181 };
1182
1183 let widget = ActionLogWidget::new(log);
1184 frame.render_widget(widget, log_area);
1185
1186 if let Some(scrollbar_area) = scrollbar_area {
1187 let visible_rows = log_area.height.saturating_sub(1) as usize;
1188 let scroll_offset = log.scroll_offset_for(visible_rows);
1189 let content_length = log.entries.len();
1190 let mut scrollbar_state = ScrollbarState::new(content_length)
1191 .position(scroll_offset)
1192 .viewport_content_length(visible_rows);
1193 let scrollbar = self.build_scrollbar(ScrollbarOrientation::VerticalRight);
1194 frame.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
1195 }
1196 }
1197
1198 fn render_action_detail_modal(
1199 &self,
1200 frame: &mut Frame,
1201 app_area: Rect,
1202 detail: &super::table::ActionDetailOverlay,
1203 ) {
1204 let modal_width = (app_area.width * 80 / 100)
1205 .clamp(40, 120)
1206 .min(app_area.width);
1207 let modal_height = (app_area.height * 50 / 100)
1208 .clamp(8, 30)
1209 .min(app_area.height);
1210
1211 let modal_x = app_area.x + (app_area.width.saturating_sub(modal_width)) / 2;
1212 let modal_y = app_area.y + (app_area.height.saturating_sub(modal_height)) / 2;
1213
1214 let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
1215
1216 frame.render_widget(Clear, modal_area);
1217
1218 let title = format!(" Action #{} - {} ", detail.sequence, detail.name);
1219
1220 let block = Block::default()
1221 .borders(Borders::ALL)
1222 .title(title)
1223 .style(self.style.banner_bg);
1224
1225 let inner = block.inner(modal_area);
1226 frame.render_widget(block, modal_area);
1227
1228 use ratatui::text::{Line, Span};
1230 use ratatui::widgets::Paragraph;
1231
1232 let label_style = Style::default().fg(DebugStyle::text_secondary());
1233 let value_style = Style::default().fg(DebugStyle::text_primary());
1234
1235 let mut lines = vec![
1236 Line::from(vec![
1238 Span::styled("Name: ", label_style),
1239 Span::styled(&detail.name, value_style),
1240 ]),
1241 Line::from(vec![
1243 Span::styled("Sequence: ", label_style),
1244 Span::styled(detail.sequence.to_string(), value_style),
1245 ]),
1246 Line::from(vec![
1248 Span::styled("Elapsed: ", label_style),
1249 Span::styled(&detail.elapsed, value_style),
1250 ]),
1251 Line::from(""),
1253 Line::from(Span::styled("Parameters:", label_style)),
1255 ];
1256
1257 if detail.params.is_empty() {
1259 lines.push(Line::from(Span::styled(" (none)", value_style)));
1260 } else {
1261 for param_line in detail.params.lines() {
1262 lines.push(Line::from(Span::styled(
1263 format!(" {}", param_line),
1264 value_style,
1265 )));
1266 }
1267 }
1268
1269 lines.push(Line::from(""));
1271 lines.push(Line::from(Span::styled(
1272 "Press Enter/Esc/Backspace to go back",
1273 label_style,
1274 )));
1275
1276 let paragraph = Paragraph::new(lines);
1277 frame.render_widget(paragraph, inner);
1278 }
1279
1280 fn build_inspect_overlay(&self, column: u16, row: u16, snapshot: &Buffer) -> DebugTableOverlay {
1281 let mut builder = DebugTableBuilder::new();
1282
1283 builder.push_section("Position");
1284 builder.push_entry("column", column.to_string());
1285 builder.push_entry("row", row.to_string());
1286
1287 if let Some(preview) = inspect_cell(snapshot, column, row) {
1288 builder.set_cell_preview(preview);
1289 }
1290
1291 builder.finish(format!("Inspect ({column}, {row})"))
1292 }
1293}
1294
1295fn format_key(key: KeyCode) -> String {
1297 match key {
1298 KeyCode::F(n) => format!("F{}", n),
1299 KeyCode::Char(c) => c.to_string(),
1300 KeyCode::Esc => "Esc".to_string(),
1301 KeyCode::Enter => "Enter".to_string(),
1302 KeyCode::Tab => "Tab".to_string(),
1303 KeyCode::Backspace => "Bksp".to_string(),
1304 KeyCode::Delete => "Del".to_string(),
1305 KeyCode::Up => "↑".to_string(),
1306 KeyCode::Down => "↓".to_string(),
1307 KeyCode::Left => "←".to_string(),
1308 KeyCode::Right => "→".to_string(),
1309 _ => format!("{:?}", key),
1310 }
1311}
1312
1313#[cfg(test)]
1314mod tests {
1315 use super::*;
1316
1317 #[derive(Debug, Clone)]
1318 enum TestAction {
1319 Foo,
1320 Bar,
1321 }
1322
1323 impl crate::Action for TestAction {
1324 fn name(&self) -> &'static str {
1325 match self {
1326 TestAction::Foo => "Foo",
1327 TestAction::Bar => "Bar",
1328 }
1329 }
1330 }
1331
1332 impl crate::ActionParams for TestAction {
1333 fn params(&self) -> String {
1334 String::new()
1335 }
1336 }
1337
1338 #[test]
1339 fn test_debug_layer_creation() {
1340 let layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
1341 assert!(!layer.is_enabled());
1342 assert!(layer.freeze().snapshot.is_none());
1343 }
1344
1345 #[test]
1346 fn test_toggle() {
1347 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
1348
1349 let effect = layer.toggle();
1351 assert!(effect.is_none());
1352 assert!(layer.is_enabled());
1353
1354 let effect = layer.toggle();
1356 assert!(effect.is_none()); assert!(!layer.is_enabled());
1358 }
1359
1360 #[test]
1361 fn test_set_enabled() {
1362 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
1363
1364 layer.set_enabled(true);
1365 assert!(layer.is_enabled());
1366
1367 layer.set_enabled(false);
1368 assert!(!layer.is_enabled());
1369 }
1370
1371 #[test]
1372 fn test_simple_constructor() {
1373 let layer: DebugLayer<TestAction> = DebugLayer::simple();
1374 assert!(!layer.is_enabled());
1375 }
1376
1377 #[test]
1378 fn test_queued_actions_returned_on_disable() {
1379 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
1380
1381 layer.toggle(); layer.queue_action(TestAction::Foo);
1383 layer.queue_action(TestAction::Bar);
1384
1385 let effect = layer.toggle(); match effect {
1388 Some(DebugSideEffect::ProcessQueuedActions(actions)) => {
1389 assert_eq!(actions.len(), 2);
1390 }
1391 _ => panic!("Expected ProcessQueuedActions"),
1392 }
1393 }
1394
1395 #[test]
1396 fn test_split_area() {
1397 let layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
1398
1399 let area = Rect::new(0, 0, 80, 24);
1401 let (app, banner) = layer.split_area(area);
1402 assert_eq!(app, area);
1403 assert_eq!(banner, Rect::ZERO);
1404 }
1405
1406 #[test]
1407 fn test_split_area_enabled() {
1408 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
1409 layer.toggle();
1410
1411 let area = Rect::new(0, 0, 80, 24);
1412 let (app, banner) = layer.split_area(area);
1413
1414 assert_eq!(app.height, 23);
1415 assert_eq!(banner.height, 1);
1416 assert_eq!(banner.y, 23);
1417 }
1418
1419 #[test]
1420 fn test_split_area_enabled_top() {
1421 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
1422 layer.toggle();
1423 layer.set_banner_position(BannerPosition::Top);
1424
1425 let area = Rect::new(0, 0, 80, 24);
1426 let (app, banner) = layer.split_area(area);
1427
1428 assert_eq!(banner.y, 0);
1429 assert_eq!(banner.height, 1);
1430 assert_eq!(app.y, 1);
1431 assert_eq!(app.height, 23);
1432 }
1433
1434 #[test]
1435 fn test_inactive_layer() {
1436 let layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12)).active(false);
1437
1438 assert!(!layer.is_active());
1439 assert!(!layer.is_enabled());
1440 }
1441
1442 #[test]
1443 fn test_action_log() {
1444 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
1445
1446 layer.log_action(&TestAction::Foo);
1447 layer.log_action(&TestAction::Bar);
1448
1449 assert_eq!(layer.action_log().entries().count(), 2);
1450 }
1451}