1use std::any::Any;
7use std::collections::HashSet;
8use std::io::{self, Write};
9use std::path::{Path, PathBuf};
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use base64::prelude::*;
13use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEventKind};
14use ratatui::buffer::Buffer;
15use ratatui::layout::Rect;
16use ratatui::style::{Modifier, Style};
17use ratatui::text::{Line, Span};
18use ratatui::Frame;
19use serde::Serialize;
20
21use super::action_logger::{ActionLog, ActionLogConfig};
22use super::actions::{DebugAction, DebugSideEffect};
23use super::cell::inspect_cell;
24use super::config::DebugStyle;
25use super::state::DebugState;
26use super::table::{
27 ActionLogOverlay, DebugOverlay, DebugTableBuilder, DebugTableOverlay, DebugTableRow,
28};
29use super::widgets::{
30 dim_buffer, paint_snapshot, ron_spans, ActionLogStyle, CellPreviewWidget, DebugTableStyle,
31 RonSyntaxStyle,
32};
33use super::DebugFreeze;
34
35use tui_dispatch_components::{
36 centered_rect, BaseStyle, LinesScroller, Modal, ModalBehavior, ModalProps, ModalStyle, Padding,
37 ScrollView, ScrollViewBehavior, ScrollViewProps, ScrollViewStyle,
38 ScrollbarStyle as ComponentScrollbarStyle, SelectionStyle, StatusBar, StatusBarItem,
39 StatusBarProps, StatusBarSection, StatusBarStyle, TreeBranchMode, TreeBranchStyle, TreeNode,
40 TreeNodeRender, TreeView, TreeViewBehavior, TreeViewProps, TreeViewStyle,
41};
42
43#[cfg(feature = "subscriptions")]
44use tui_dispatch_core::subscriptions::SubPauseHandle;
45#[cfg(feature = "tasks")]
46use tui_dispatch_core::tasks::TaskPauseHandle;
47use tui_dispatch_core::{Action, Component, EventKind};
48
49type StateSnapshotter = Box<dyn Fn(&dyn Any, &Path) -> crate::SnapshotResult<()> + 'static>;
50
51#[derive(Debug, Clone)]
52enum StateTreeAction {
53 Select(String),
54 Toggle(String, bool),
55}
56
57fn state_tree_select(id: &str) -> StateTreeAction {
58 StateTreeAction::Select(id.to_owned())
59}
60
61fn state_tree_toggle(id: &str, expanded: bool) -> StateTreeAction {
62 StateTreeAction::Toggle(id.to_owned(), expanded)
63}
64
65struct InlineValueStyle {
66 base: Style,
67 key: Style,
68 string: Style,
69 number: Style,
70 r#type: Style,
71}
72
73fn state_tree_label_width(node: &TreeNode<String, String>) -> usize {
74 let value = node.value.as_str();
75 if node.children.is_empty() {
76 if let Some((key, _)) = value.split_once(": ") {
77 return key.chars().count().saturating_add(1);
78 }
79 }
80 value.chars().count()
81}
82
83fn ron_value_spans(value: &str, style: &InlineValueStyle) -> Vec<Span<'static>> {
84 let chars: Vec<char> = value.chars().collect();
85 let mut spans = Vec::new();
86 let mut idx = 0;
87
88 while idx < chars.len() {
89 let current = chars[idx];
90 if current == '"' {
91 let start = idx;
92 idx += 1;
93 while idx < chars.len() {
94 if chars[idx] == '"' && chars.get(idx.saturating_sub(1)) != Some(&'\\') {
95 idx += 1;
96 break;
97 }
98 idx += 1;
99 }
100 let text: String = chars[start..idx].iter().collect();
101 spans.push(Span::styled(text, style.string));
102 continue;
103 }
104
105 if current.is_ascii_digit()
106 || (current == '-' && chars.get(idx + 1).is_some_and(|c| c.is_ascii_digit()))
107 {
108 let start = idx;
109 idx += 1;
110 while idx < chars.len() {
111 let c = chars[idx];
112 if c.is_ascii_digit() || matches!(c, '.' | '_' | 'e' | 'E' | '+' | '-') {
113 idx += 1;
114 } else {
115 break;
116 }
117 }
118 let text: String = chars[start..idx].iter().collect();
119 spans.push(Span::styled(text, style.number));
120 continue;
121 }
122
123 if current.is_alphanumeric() || current == '_' {
124 let start = idx;
125 idx += 1;
126 while idx < chars.len() {
127 let c = chars[idx];
128 if c.is_alphanumeric() || c == '_' {
129 idx += 1;
130 } else {
131 break;
132 }
133 }
134 let mut text: String = chars[start..idx].iter().collect();
135 let mut span_style = style.base;
136
137 if idx < chars.len() && chars[idx] == ':' {
138 text.push(':');
139 idx += 1;
140 span_style = style.key;
141 } else if text == "true" || text == "false" {
142 span_style = style.number;
143 } else if text.chars().next().is_some_and(|c| c.is_uppercase()) {
144 span_style = style.r#type;
145 }
146
147 spans.push(Span::styled(text, span_style));
148 continue;
149 }
150
151 spans.push(Span::styled(current.to_string(), style.base));
152 idx += 1;
153 }
154
155 spans
156}
157
158fn render_state_tree_node(ctx: TreeNodeRender<'_, String, String>) -> Line<'static> {
159 let label_style = Style::default()
160 .fg(DebugStyle::accent())
161 .add_modifier(Modifier::BOLD);
162 let key_style = Style::default()
163 .fg(DebugStyle::text_secondary())
164 .add_modifier(Modifier::DIM);
165 let value_style = Style::default().fg(DebugStyle::text_primary());
166 let type_style = Style::default().fg(DebugStyle::neon_cyan());
167 let string_style = Style::default().fg(DebugStyle::neon_green());
168 let number_style = Style::default().fg(DebugStyle::neon_purple());
169 let selection_patch = if ctx.is_selected {
170 Style::default().bg(DebugStyle::bg_highlight())
171 } else {
172 Style::default()
173 };
174
175 let content_width = ctx.available_width.max(1);
176 let tree_width = ctx.tree_column_width.max(1).min(content_width);
177 let value_width = content_width.saturating_sub(tree_width);
178
179 let mut spans = Vec::new();
180
181 if ctx.has_children {
182 let text = truncate_with_ellipsis(&ctx.node.value, tree_width);
183 let label_len = text.chars().count();
184 let label = Span::styled(text, label_style).patch_style(selection_patch);
185 spans.push(label);
186 let padding = tree_width.saturating_sub(label_len);
187 if padding > 0 {
188 spans.push(Span::styled(" ".repeat(padding), value_style));
189 }
190 return Line::from(spans);
191 }
192
193 if let Some((key, value)) = ctx.node.value.split_once(": ") {
194 let field_text = format!("{key}:");
195 let field_text = truncate_with_ellipsis(&field_text, tree_width);
196 let field_len = field_text.chars().count();
197 let field = Span::styled(field_text, key_style).patch_style(selection_patch);
198 spans.push(field);
199 let padding = tree_width.saturating_sub(field_len);
200 if padding > 0 {
201 spans.push(Span::styled(" ".repeat(padding), value_style));
202 }
203
204 if value_width > 0 {
205 let value_text = truncate_with_ellipsis(value, value_width);
206 let inline_style = InlineValueStyle {
207 base: value_style,
208 key: key_style,
209 string: string_style,
210 number: number_style,
211 r#type: type_style,
212 };
213 let value_spans = ron_value_spans(&value_text, &inline_style);
214 for span in value_spans {
215 spans.push(span.patch_style(selection_patch));
216 }
217 }
218
219 return Line::from(spans);
220 }
221
222 let text = truncate_with_ellipsis(&ctx.node.value, tree_width);
223 let label_len = text.chars().count();
224 let label = Span::styled(text, value_style).patch_style(selection_patch);
225 spans.push(label);
226 let padding = tree_width.saturating_sub(label_len);
227 if padding > 0 {
228 spans.push(Span::styled(" ".repeat(padding), value_style));
229 }
230
231 Line::from(spans)
232}
233
234#[derive(Clone, Copy, Debug, PartialEq, Eq)]
236pub enum BannerPosition {
237 Bottom,
238 Top,
239}
240
241impl BannerPosition {
242 pub fn toggle(self) -> Self {
244 match self {
245 Self::Bottom => Self::Top,
246 Self::Top => Self::Bottom,
247 }
248 }
249
250 fn label(self) -> &'static str {
251 match self {
252 Self::Bottom => "bar:bottom",
253 Self::Top => "bar:top",
254 }
255 }
256}
257
258pub struct DebugOutcome<A> {
260 pub consumed: bool,
262 pub queued_actions: Vec<A>,
264 pub needs_render: bool,
266}
267
268impl<A> DebugOutcome<A> {
269 fn ignored() -> Self {
270 Self {
271 consumed: false,
272 queued_actions: Vec::new(),
273 needs_render: false,
274 }
275 }
276
277 fn consumed(queued_actions: Vec<A>) -> Self {
278 Self {
279 consumed: true,
280 queued_actions,
281 needs_render: true,
282 }
283 }
284
285 pub fn dispatch_queued<F>(self, mut dispatch: F) -> Option<bool>
289 where
290 F: FnMut(A),
291 {
292 if !self.consumed {
293 return None;
294 }
295
296 for action in self.queued_actions {
297 dispatch(action);
298 }
299
300 Some(self.needs_render)
301 }
302}
303
304impl<A> Default for DebugOutcome<A> {
305 fn default() -> Self {
306 Self::ignored()
307 }
308}
309
310pub struct DebugLayer<A> {
339 toggle_key: KeyCode,
341 freeze: DebugFreeze<A>,
343 banner_position: BannerPosition,
345 style: DebugStyle,
347 active: bool,
349 action_log: ActionLog,
351 state_snapshot: Option<DebugTableOverlay>,
353 state_snapshotter: Option<StateSnapshotter>,
355 state_tree_view: TreeView<String>,
357 state_tree_nodes: Vec<TreeNode<String, String>>,
359 state_tree_selected: Option<String>,
361 state_tree_expanded: HashSet<String>,
363 table_scroll_offset: usize,
365 table_page_size: usize,
367 detail_scroll_offset: usize,
369 detail_page_size: usize,
371 #[cfg(feature = "tasks")]
373 task_handle: Option<TaskPauseHandle<A>>,
374 #[cfg(feature = "subscriptions")]
376 sub_handle: Option<SubPauseHandle>,
377}
378
379impl<A> std::fmt::Debug for DebugLayer<A> {
380 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
381 f.debug_struct("DebugLayer")
382 .field("toggle_key", &self.toggle_key)
383 .field("active", &self.active)
384 .field("enabled", &self.freeze.enabled)
385 .field("has_snapshot", &self.freeze.snapshot.is_some())
386 .field("has_state_snapshot", &self.state_snapshot.is_some())
387 .field("has_state_snapshotter", &self.state_snapshotter.is_some())
388 .field("state_tree_nodes", &self.state_tree_nodes.len())
389 .field("state_tree_selected", &self.state_tree_selected)
390 .field("banner_position", &self.banner_position)
391 .field("table_scroll_offset", &self.table_scroll_offset)
392 .field("detail_scroll_offset", &self.detail_scroll_offset)
393 .field("queued_actions", &self.freeze.queued_actions.len())
394 .finish()
395 }
396}
397
398impl<A: Action> DebugLayer<A> {
399 pub fn new(toggle_key: KeyCode) -> Self {
409 Self {
410 toggle_key,
411 freeze: DebugFreeze::new(),
412 banner_position: BannerPosition::Bottom,
413 style: DebugStyle::default(),
414 active: true,
415 action_log: ActionLog::new(ActionLogConfig::with_capacity(100)),
416 state_snapshot: None,
417 state_snapshotter: None,
418 state_tree_view: TreeView::new(),
419 state_tree_nodes: Vec::new(),
420 state_tree_selected: None,
421 state_tree_expanded: HashSet::new(),
422 table_scroll_offset: 0,
423 table_page_size: 1,
424 detail_scroll_offset: 0,
425 detail_page_size: 1,
426 #[cfg(feature = "tasks")]
427 task_handle: None,
428 #[cfg(feature = "subscriptions")]
429 sub_handle: None,
430 }
431 }
432
433 pub fn simple() -> Self {
435 Self::new(KeyCode::F(12))
436 }
437
438 pub fn simple_with_toggle_key(toggle_key: KeyCode) -> Self {
440 Self::new(toggle_key)
441 }
442
443 pub fn active(mut self, active: bool) -> Self {
447 self.active = active;
448 self
449 }
450
451 pub fn with_banner_position(mut self, position: BannerPosition) -> Self {
453 self.banner_position = position;
454 self
455 }
456
457 #[cfg(feature = "tasks")]
462 pub fn with_task_manager(mut self, tasks: &tui_dispatch_core::tasks::TaskManager<A>) -> Self {
463 self.task_handle = Some(tasks.pause_handle());
464 self
465 }
466
467 #[cfg(feature = "subscriptions")]
471 pub fn with_subscriptions(
472 mut self,
473 subs: &tui_dispatch_core::subscriptions::Subscriptions<A>,
474 ) -> Self {
475 self.sub_handle = Some(subs.pause_handle());
476 self
477 }
478
479 pub fn with_action_log_capacity(mut self, capacity: usize) -> Self {
481 self.action_log = ActionLog::new(ActionLogConfig::with_capacity(capacity));
482 self
483 }
484
485 pub fn with_style(mut self, style: DebugStyle) -> Self {
487 self.style = style;
488 self
489 }
490
491 pub fn with_state_snapshotter<S, F>(mut self, snapshotter: F) -> Self
493 where
494 S: DebugState + 'static,
495 F: Fn(&S, &Path) -> crate::SnapshotResult<()> + 'static,
496 {
497 self.state_snapshotter = Some(Box::new(move |state, path| {
498 let state = state.downcast_ref::<S>().ok_or_else(|| {
499 crate::SnapshotError::Io(io::Error::new(
500 io::ErrorKind::InvalidInput,
501 "debug state snapshot type mismatch",
502 ))
503 })?;
504 snapshotter(state, path)
505 }));
506 self
507 }
508
509 pub fn with_state_snapshots<S>(self) -> Self
511 where
512 S: DebugState + Serialize + 'static,
513 {
514 self.with_state_snapshotter::<S, _>(|state, path| crate::save_json(path, state))
515 }
516
517 pub fn is_active(&self) -> bool {
519 self.active
520 }
521
522 pub fn is_enabled(&self) -> bool {
524 self.active && self.freeze.enabled
525 }
526
527 pub fn toggle_enabled(&mut self) -> Option<DebugSideEffect<A>> {
531 if !self.active {
532 return None;
533 }
534 self.toggle()
535 }
536
537 pub fn set_enabled(&mut self, enabled: bool) -> Option<DebugSideEffect<A>> {
541 if !self.active || enabled == self.freeze.enabled {
542 return None;
543 }
544 self.toggle()
545 }
546
547 pub fn set_banner_position(&mut self, position: BannerPosition) {
549 if self.banner_position != position {
550 self.banner_position = position;
551 if self.freeze.enabled {
552 self.freeze.request_capture();
553 }
554 }
555 }
556
557 pub fn is_state_overlay_visible(&self) -> bool {
559 matches!(self.freeze.overlay, Some(DebugOverlay::State(_)))
560 }
561
562 pub fn freeze(&self) -> &DebugFreeze<A> {
564 &self.freeze
565 }
566
567 pub fn freeze_mut(&mut self) -> &mut DebugFreeze<A> {
569 &mut self.freeze
570 }
571
572 pub fn log_action<T: tui_dispatch_core::ActionParams>(&mut self, action: &T) {
576 if self.active {
577 self.action_log.log(action);
578 }
579 }
580
581 pub fn action_log(&self) -> &ActionLog {
583 &self.action_log
584 }
585
586 pub fn render<F>(&mut self, frame: &mut Frame, render_fn: F)
591 where
592 F: FnOnce(&mut Frame, Rect),
593 {
594 self.render_with_state(frame, |frame, area, _wants_state| {
595 render_fn(frame, area);
596 None
597 });
598 }
599
600 pub fn render_with_state<F>(&mut self, frame: &mut Frame, render_fn: F)
606 where
607 F: FnOnce(&mut Frame, Rect, bool) -> Option<DebugTableOverlay>,
608 {
609 let screen = frame.area();
610
611 if !self.active || !self.freeze.enabled {
613 let _ = render_fn(frame, screen, false);
614 return;
615 }
616
617 let (app_area, banner_area) = self.split_for_banner(screen);
619
620 if self.freeze.pending_capture || self.freeze.snapshot.is_none() {
621 let state_snapshot = render_fn(frame, app_area, true);
623 self.state_snapshot = state_snapshot;
624 if let Some(ref table) = self.state_snapshot {
625 if self.is_state_overlay_visible() {
626 self.set_state_overlay(table.clone());
627 }
628 }
629 let buffer_clone = frame.buffer_mut().clone();
630 self.freeze.capture(&buffer_clone);
631 } else if let Some(ref snapshot) = self.freeze.snapshot {
632 paint_snapshot(frame, snapshot);
634 }
635
636 self.render_debug_overlay(frame, app_area, banner_area);
638 }
639
640 pub fn render_state<S: DebugState, F>(&mut self, frame: &mut Frame, state: &S, render_fn: F)
644 where
645 F: FnOnce(&mut Frame, Rect),
646 {
647 self.render_with_state(frame, |frame, area, wants_state| {
648 render_fn(frame, area);
649 if wants_state {
650 Some(state.build_debug_table("Application State"))
651 } else {
652 None
653 }
654 });
655 }
656
657 pub fn split_area(&self, area: Rect) -> (Rect, Rect) {
659 if !self.freeze.enabled {
660 return (area, Rect::ZERO);
661 }
662 self.split_for_banner(area)
663 }
664
665 pub fn intercepts(&mut self, event: &tui_dispatch_core::EventKind) -> bool {
679 self.intercepts_with_effects(event).is_some()
680 }
681
682 pub fn handle_event(&mut self, event: &tui_dispatch_core::EventKind) -> DebugOutcome<A> {
684 self.handle_event_internal::<()>(event, None)
685 }
686
687 pub fn handle_event_with_state<S: DebugState + 'static>(
689 &mut self,
690 event: &tui_dispatch_core::EventKind,
691 state: &S,
692 ) -> DebugOutcome<A> {
693 self.handle_event_internal(event, Some(state))
694 }
695
696 pub fn intercepts_with_effects(
700 &mut self,
701 event: &tui_dispatch_core::EventKind,
702 ) -> Option<Vec<DebugSideEffect<A>>> {
703 self.intercepts_with_effects_internal::<()>(event, None)
704 }
705
706 pub fn intercepts_with_effects_and_state<S: DebugState + 'static>(
710 &mut self,
711 event: &tui_dispatch_core::EventKind,
712 state: &S,
713 ) -> Option<Vec<DebugSideEffect<A>>> {
714 self.intercepts_with_effects_internal(event, Some(state))
715 }
716
717 pub fn intercepts_with_state<S: DebugState + 'static>(
719 &mut self,
720 event: &tui_dispatch_core::EventKind,
721 state: &S,
722 ) -> bool {
723 self.intercepts_with_effects_internal(event, Some(state))
724 .is_some()
725 }
726
727 fn handle_event_internal<S: DebugState + 'static>(
728 &mut self,
729 event: &tui_dispatch_core::EventKind,
730 state: Option<&S>,
731 ) -> DebugOutcome<A> {
732 let effects = self.intercepts_with_effects_internal(event, state);
733 let Some(effects) = effects else {
734 return DebugOutcome::ignored();
735 };
736
737 let mut queued_actions = Vec::new();
738 for effect in effects {
739 if let DebugSideEffect::ProcessQueuedActions(actions) = effect {
740 queued_actions.extend(actions);
741 }
742 }
743
744 DebugOutcome::consumed(queued_actions)
745 }
746
747 fn intercepts_with_effects_internal<S: DebugState + 'static>(
748 &mut self,
749 event: &tui_dispatch_core::EventKind,
750 state: Option<&S>,
751 ) -> Option<Vec<DebugSideEffect<A>>> {
752 if !self.active {
753 return None;
754 }
755
756 use tui_dispatch_core::EventKind;
757
758 match event {
759 EventKind::Key(key) => self.handle_key_event(*key, state),
760 EventKind::Mouse(mouse) => {
761 if !self.freeze.enabled {
762 return None;
763 }
764
765 if !self.freeze.mouse_capture_enabled {
768 return None;
769 }
770
771 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
773 let effect = self.handle_action(DebugAction::InspectCell {
774 column: mouse.column,
775 row: mouse.row,
776 });
777 return Some(effect.into_iter().collect());
778 }
779
780 Some(vec![])
782 }
783 EventKind::Scroll { delta, column, row } => {
784 if !self.freeze.enabled {
785 return None;
786 }
787
788 match self.freeze.overlay.as_ref() {
789 Some(DebugOverlay::ActionLog(_)) => {
790 let action = if *delta > 0 {
791 DebugAction::ActionLogScrollUp
792 } else {
793 DebugAction::ActionLogScrollDown
794 };
795 self.handle_action(action);
796 }
797 Some(DebugOverlay::State(_)) => {
798 self.sync_state_tree_state();
799 let nodes = &self.state_tree_nodes;
800 let selected_id = self.state_tree_selected.as_ref();
801 let expanded_ids = &self.state_tree_expanded;
802 let style = self.state_tree_style();
803 let props =
804 Self::build_state_tree_props(nodes, selected_id, expanded_ids, style);
805 let actions: Vec<_> = self
806 .state_tree_view
807 .handle_event(
808 &EventKind::Scroll {
809 delta: *delta,
810 column: *column,
811 row: *row,
812 },
813 props,
814 )
815 .into_iter()
816 .collect();
817 if !actions.is_empty() {
818 self.apply_state_tree_actions(actions);
819 }
820 }
821 Some(DebugOverlay::Inspect(table)) => {
822 if *delta > 0 {
823 self.scroll_table_up();
824 } else {
825 self.scroll_table_down(table.rows.len());
826 }
827 }
828 Some(DebugOverlay::ActionDetail(detail)) => {
829 let params_lines = self.detail_params_lines(detail);
830 if *delta > 0 {
831 self.scroll_detail_up();
832 } else {
833 self.scroll_detail_down(params_lines.len());
834 }
835 }
836 _ => {}
837 }
838
839 Some(vec![])
840 }
841 EventKind::Resize(_, _) | EventKind::Tick => None,
843 }
844 }
845
846 pub fn show_state_overlay<S: DebugState>(&mut self, state: &S) {
848 let table = state.build_debug_table("Application State");
849 self.set_state_overlay(table);
850 }
851
852 pub fn show_action_log(&mut self) {
854 let overlay = ActionLogOverlay::from_log(&self.action_log, "Action Log");
855 self.freeze.set_overlay(DebugOverlay::ActionLog(overlay));
856 }
857
858 pub fn queue_action(&mut self, action: A) {
860 self.freeze.queue(action);
861 }
862
863 pub fn take_queued_actions(&mut self) -> Vec<A> {
868 std::mem::take(&mut self.freeze.queued_actions)
869 }
870
871 fn set_state_overlay(&mut self, table: DebugTableOverlay) {
876 let is_new_overlay = !matches!(self.freeze.overlay, Some(DebugOverlay::State(_)));
877 if is_new_overlay {
878 self.table_scroll_offset = 0;
879 self.state_tree_view = TreeView::new();
880 self.state_tree_selected = None;
881 self.state_tree_expanded.clear();
882 }
883
884 self.state_tree_nodes = self.build_state_tree_nodes(&table);
885 if is_new_overlay {
886 self.state_tree_expanded = self
887 .state_tree_nodes
888 .iter()
889 .map(|node| node.id.clone())
890 .collect();
891 }
892 self.sync_state_tree_state();
893 self.state_snapshot = Some(table.clone());
894 self.freeze.set_overlay(DebugOverlay::State(table));
895 }
896
897 fn build_state_tree_nodes(&self, table: &DebugTableOverlay) -> Vec<TreeNode<String, String>> {
898 let mut sections: Vec<TreeNode<String, String>> = Vec::new();
899 let mut current: Option<TreeNode<String, String>> = None;
900 let mut section_index = 0usize;
901 let mut entry_index = 0usize;
902 let mut current_section_index = 0usize;
903
904 for row in &table.rows {
905 match row {
906 DebugTableRow::Section(title) => {
907 if let Some(section) = current.take() {
908 sections.push(section);
909 }
910 current_section_index = section_index;
911 entry_index = 0;
912 let id = format!("section:{section_index}:{title}");
913 section_index = section_index.saturating_add(1);
914 current = Some(TreeNode::with_children(id, title.clone(), Vec::new()));
915 }
916 DebugTableRow::Entry { key, value } => {
917 if current.is_none() {
918 current_section_index = section_index;
919 entry_index = 0;
920 let id = format!("section:{section_index}:State");
921 section_index = section_index.saturating_add(1);
922 current =
923 Some(TreeNode::with_children(id, "State".to_string(), Vec::new()));
924 }
925 if let Some(section) = current.as_mut() {
926 let entry_id = format!("entry:{current_section_index}:{entry_index}:{key}");
927 entry_index = entry_index.saturating_add(1);
928 section
929 .children
930 .push(TreeNode::new(entry_id, format!("{key}: {value}")));
931 }
932 }
933 }
934 }
935
936 if let Some(section) = current.take() {
937 sections.push(section);
938 }
939
940 sections
941 }
942
943 fn sync_state_tree_state(&mut self) {
944 let nodes = &self.state_tree_nodes;
945 self.state_tree_expanded
946 .retain(|id| Self::tree_contains_id(nodes, id));
947
948 let selected_valid = self
949 .state_tree_selected
950 .as_deref()
951 .map(|id| self.state_tree_contains_id(id))
952 .unwrap_or(false);
953
954 if !selected_valid {
955 self.state_tree_selected = self.state_tree_nodes.first().map(|node| node.id.clone());
956 }
957 }
958
959 fn state_tree_contains_id(&self, id: &str) -> bool {
960 Self::tree_contains_id(&self.state_tree_nodes, id)
961 }
962
963 fn tree_contains_id(nodes: &[TreeNode<String, String>], id: &str) -> bool {
964 nodes
965 .iter()
966 .any(|node| node.id == id || Self::tree_contains_id(&node.children, id))
967 }
968
969 fn state_tree_style(&self) -> TreeViewStyle {
970 TreeViewStyle {
971 base: BaseStyle {
972 border: None,
973 padding: Padding::default(),
974 bg: Some(DebugStyle::overlay_bg()),
975 fg: Some(DebugStyle::text_primary()),
976 },
977 selection: SelectionStyle::disabled(),
978 scrollbar: self.component_scrollbar_style(),
979 branches: TreeBranchStyle {
980 mode: TreeBranchMode::Branch,
981 ..Default::default()
982 },
983 }
984 }
985
986 fn build_state_tree_props<'a>(
987 nodes: &'a [TreeNode<String, String>],
988 selected_id: Option<&'a String>,
989 expanded_ids: &'a HashSet<String>,
990 style: TreeViewStyle,
991 ) -> TreeViewProps<'a, String, String, StateTreeAction> {
992 TreeViewProps {
993 nodes,
994 selected_id,
995 expanded_ids,
996 is_focused: true,
997 style,
998 behavior: TreeViewBehavior::default(),
999 measure_node: Some(&state_tree_label_width),
1000 column_padding: 2,
1001 on_select: |id| state_tree_select(id),
1002 on_toggle: |id, expanded| state_tree_toggle(id, expanded),
1003 render_node: &render_state_tree_node,
1004 }
1005 }
1006
1007 fn apply_state_tree_actions(&mut self, actions: impl IntoIterator<Item = StateTreeAction>) {
1008 for action in actions {
1009 match action {
1010 StateTreeAction::Select(id) => {
1011 self.state_tree_selected = Some(id);
1012 }
1013 StateTreeAction::Toggle(id, expand) => {
1014 if expand {
1015 self.state_tree_expanded.insert(id);
1016 } else {
1017 self.state_tree_expanded.remove(&id);
1018 }
1019 }
1020 }
1021 }
1022 }
1023
1024 fn save_state_snapshot<S: DebugState + 'static>(&mut self, state: Option<&S>) {
1025 if !matches!(self.freeze.overlay, Some(DebugOverlay::State(_))) {
1026 self.freeze.set_message("Open state overlay (S) to save");
1027 return;
1028 }
1029
1030 let Some(state) = state else {
1031 self.freeze
1032 .set_message("State unavailable: call render_state() first");
1033 return;
1034 };
1035
1036 let path = self.state_snapshot_path();
1037
1038 let result = if let Some(snapshotter) = self.state_snapshotter.as_ref() {
1039 snapshotter(state as &dyn Any, &path)
1040 } else {
1041 let snapshot = DebugStateSnapshot::from_state(state, "Application State");
1042 crate::save_json(&path, &snapshot)
1043 };
1044
1045 match result {
1046 Ok(()) => self
1047 .freeze
1048 .set_message(format!("Saved state: {}", path.display())),
1049 Err(err) => self
1050 .freeze
1051 .set_message(format!("State save failed: {err:?}")),
1052 }
1053 }
1054
1055 fn state_snapshot_path(&self) -> PathBuf {
1056 let timestamp = SystemTime::now()
1057 .duration_since(UNIX_EPOCH)
1058 .unwrap_or_default()
1059 .as_millis();
1060 PathBuf::from(format!("debug-state-{timestamp}.json"))
1061 }
1062
1063 #[allow(dead_code)]
1064 fn update_table_scroll(&mut self, table: &DebugTableOverlay, visible_rows: usize) {
1065 self.table_page_size = visible_rows.max(1);
1066 let max_offset = table.rows.len().saturating_sub(visible_rows);
1067 self.table_scroll_offset = self.table_scroll_offset.min(max_offset);
1068 }
1069
1070 fn overlay_modal_area(&self, app_area: Rect) -> Rect {
1071 let modal_width = (app_area.width * 80 / 100)
1072 .clamp(40, 160)
1073 .min(app_area.width);
1074 let modal_height = (app_area.height * 80 / 100)
1075 .clamp(12, 50)
1076 .min(app_area.height);
1077 centered_rect(modal_width, modal_height, app_area)
1078 }
1079
1080 fn overlay_modal_style(&self) -> ModalStyle {
1081 ModalStyle {
1082 dim_factor: 0.0,
1083 base: BaseStyle {
1084 border: None,
1085 padding: Padding::all(1),
1086 bg: Some(DebugStyle::overlay_bg()),
1087 fg: None,
1088 },
1089 }
1090 }
1091
1092 fn render_overlay_container<F>(
1093 &self,
1094 frame: &mut Frame,
1095 app_area: Rect,
1096 title: &str,
1097 mut render_body: F,
1098 ) where
1099 F: FnMut(&mut Frame, Rect),
1100 {
1101 let modal_area = self.overlay_modal_area(app_area);
1102 let mut modal = Modal::new();
1103 let mut render_content = |frame: &mut Frame, content_area: Rect| {
1104 if content_area.height == 0 || content_area.width == 0 {
1105 return;
1106 }
1107
1108 let content_area = self.render_overlay_title(frame, content_area, title);
1109 if content_area.height == 0 || content_area.width == 0 {
1110 return;
1111 }
1112
1113 render_body(frame, content_area);
1114 };
1115
1116 modal.render(
1117 frame,
1118 app_area,
1119 ModalProps {
1120 is_open: true,
1121 is_focused: false,
1122 area: modal_area,
1123 style: self.overlay_modal_style(),
1124 behavior: ModalBehavior::default(),
1125 on_close: || (),
1126 render_content: &mut render_content,
1127 },
1128 );
1129 }
1130
1131 fn render_overlay_title(&self, frame: &mut Frame, area: Rect, title: &str) -> Rect {
1132 if area.height == 0 || area.width == 0 {
1133 return area;
1134 }
1135
1136 use ratatui::text::Span;
1137
1138 let title_style = Style::default()
1139 .fg(DebugStyle::accent())
1140 .add_modifier(Modifier::BOLD);
1141 let title_items = [StatusBarItem::span(Span::styled(
1142 format!(" {title} "),
1143 title_style,
1144 ))];
1145 let title_bar_style = StatusBarStyle {
1146 base: BaseStyle {
1147 border: None,
1148 padding: Padding::default(),
1149 bg: None,
1150 fg: None,
1151 },
1152 text: Style::default().fg(DebugStyle::accent()),
1153 hint_key: title_style,
1154 hint_label: Style::default().fg(DebugStyle::accent()),
1155 separator: Style::default().fg(DebugStyle::accent()),
1156 };
1157 let title_area = Rect { height: 1, ..area };
1158
1159 let mut status_bar = StatusBar::new();
1160 <StatusBar as tui_dispatch_core::Component<()>>::render(
1161 &mut status_bar,
1162 frame,
1163 title_area,
1164 StatusBarProps {
1165 left: StatusBarSection::empty(),
1166 center: StatusBarSection::items(&title_items),
1167 right: StatusBarSection::empty(),
1168 style: title_bar_style,
1169 is_focused: false,
1170 },
1171 );
1172
1173 Rect {
1174 x: area.x,
1175 y: area.y.saturating_add(title_area.height),
1176 width: area.width,
1177 height: area.height.saturating_sub(title_area.height),
1178 }
1179 }
1180
1181 fn component_scrollbar_style(&self) -> ComponentScrollbarStyle {
1182 let style = &self.style.scrollbar;
1183 ComponentScrollbarStyle {
1184 thumb: style.thumb,
1185 track: style.track,
1186 begin: style.begin,
1187 end: style.end,
1188 thumb_symbol: style.thumb_symbol,
1189 track_symbol: style.track_symbol,
1190 begin_symbol: style.begin_symbol,
1191 end_symbol: style.end_symbol,
1192 }
1193 }
1194
1195 fn table_page_size_value(&self) -> usize {
1196 self.table_page_size.max(1)
1197 }
1198
1199 fn table_max_offset(&self, rows_len: usize) -> usize {
1200 rows_len.saturating_sub(self.table_page_size_value())
1201 }
1202
1203 fn scroll_table_up(&mut self) {
1204 self.table_scroll_offset = self.table_scroll_offset.saturating_sub(1);
1205 }
1206
1207 fn scroll_table_down(&mut self, rows_len: usize) {
1208 let max_offset = self.table_max_offset(rows_len);
1209 self.table_scroll_offset = (self.table_scroll_offset + 1).min(max_offset);
1210 }
1211
1212 fn scroll_table_to_top(&mut self) {
1213 self.table_scroll_offset = 0;
1214 }
1215
1216 fn scroll_table_to_bottom(&mut self, rows_len: usize) {
1217 self.table_scroll_offset = self.table_max_offset(rows_len);
1218 }
1219
1220 fn scroll_table_page_up(&mut self) {
1221 let page_size = self.table_page_size_value();
1222 self.table_scroll_offset = self.table_scroll_offset.saturating_sub(page_size);
1223 }
1224
1225 fn scroll_table_page_down(&mut self, rows_len: usize) {
1226 let page_size = self.table_page_size_value();
1227 let max_offset = self.table_max_offset(rows_len);
1228 self.table_scroll_offset = (self.table_scroll_offset + page_size).min(max_offset);
1229 }
1230
1231 fn detail_page_size_value(&self) -> usize {
1232 self.detail_page_size.max(1)
1233 }
1234
1235 fn detail_max_offset(&self, rows_len: usize) -> usize {
1236 rows_len.saturating_sub(self.detail_page_size_value())
1237 }
1238
1239 fn scroll_detail_up(&mut self) {
1240 self.detail_scroll_offset = self.detail_scroll_offset.saturating_sub(1);
1241 }
1242
1243 fn scroll_detail_down(&mut self, rows_len: usize) {
1244 let max_offset = self.detail_max_offset(rows_len);
1245 self.detail_scroll_offset = (self.detail_scroll_offset + 1).min(max_offset);
1246 }
1247
1248 fn scroll_detail_to_top(&mut self) {
1249 self.detail_scroll_offset = 0;
1250 }
1251
1252 fn scroll_detail_to_bottom(&mut self, rows_len: usize) {
1253 self.detail_scroll_offset = self.detail_max_offset(rows_len);
1254 }
1255
1256 fn scroll_detail_page_up(&mut self) {
1257 let page_size = self.detail_page_size_value();
1258 self.detail_scroll_offset = self.detail_scroll_offset.saturating_sub(page_size);
1259 }
1260
1261 fn scroll_detail_page_down(&mut self, rows_len: usize) {
1262 let page_size = self.detail_page_size_value();
1263 let max_offset = self.detail_max_offset(rows_len);
1264 self.detail_scroll_offset = (self.detail_scroll_offset + page_size).min(max_offset);
1265 }
1266
1267 fn handle_table_scroll_key(&mut self, key: KeyCode, rows_len: usize) -> bool {
1268 match key {
1269 KeyCode::Char('j') | KeyCode::Down => {
1270 self.scroll_table_down(rows_len);
1271 true
1272 }
1273 KeyCode::Char('k') | KeyCode::Up => {
1274 self.scroll_table_up();
1275 true
1276 }
1277 KeyCode::Char('g') => {
1278 self.scroll_table_to_top();
1279 true
1280 }
1281 KeyCode::Char('G') => {
1282 self.scroll_table_to_bottom(rows_len);
1283 true
1284 }
1285 KeyCode::PageDown => {
1286 self.scroll_table_page_down(rows_len);
1287 true
1288 }
1289 KeyCode::PageUp => {
1290 self.scroll_table_page_up();
1291 true
1292 }
1293 _ => false,
1294 }
1295 }
1296
1297 fn handle_detail_scroll_key(&mut self, key: KeyCode, rows_len: usize) -> bool {
1298 match key {
1299 KeyCode::Char('j') | KeyCode::Down => {
1300 self.scroll_detail_down(rows_len);
1301 true
1302 }
1303 KeyCode::Char('k') | KeyCode::Up => {
1304 self.scroll_detail_up();
1305 true
1306 }
1307 KeyCode::Char('g') => {
1308 self.scroll_detail_to_top();
1309 true
1310 }
1311 KeyCode::Char('G') => {
1312 self.scroll_detail_to_bottom(rows_len);
1313 true
1314 }
1315 KeyCode::PageDown => {
1316 self.scroll_detail_page_down(rows_len);
1317 true
1318 }
1319 KeyCode::PageUp => {
1320 self.scroll_detail_page_up();
1321 true
1322 }
1323 _ => false,
1324 }
1325 }
1326
1327 fn handle_key_event<S: DebugState + 'static>(
1328 &mut self,
1329 key: KeyEvent,
1330 state: Option<&S>,
1331 ) -> Option<Vec<DebugSideEffect<A>>> {
1332 if key.code == self.toggle_key && key.modifiers.is_empty() {
1334 let effect = self.toggle();
1335 return Some(effect.into_iter().collect());
1336 }
1337
1338 if self.freeze.enabled && key.code == KeyCode::Esc {
1340 let effect = self.toggle();
1341 return Some(effect.into_iter().collect());
1342 }
1343
1344 if !self.freeze.enabled {
1346 return None;
1347 }
1348
1349 match key.code {
1350 KeyCode::Char('b') | KeyCode::Char('B') => {
1351 self.banner_position = self.banner_position.toggle();
1352 self.freeze.request_capture();
1353 return Some(vec![]);
1354 }
1355 KeyCode::Char('s') | KeyCode::Char('S') => {
1356 if matches!(self.freeze.overlay, Some(DebugOverlay::State(_))) {
1357 self.freeze.clear_overlay();
1358 } else if let Some(state) = state {
1359 let table = state.build_debug_table("Application State");
1360 self.set_state_overlay(table);
1361 } else if let Some(ref table) = self.state_snapshot {
1362 self.set_state_overlay(table.clone());
1363 } else {
1364 let table = DebugTableBuilder::new()
1365 .section("State")
1366 .entry(
1367 "hint",
1368 "Press 's' after providing state via render_with_state() or show_state_overlay()",
1369 )
1370 .finish("Application State");
1371 self.freeze.set_overlay(DebugOverlay::State(table));
1372 }
1373 return Some(vec![]);
1374 }
1375 KeyCode::Char('w') | KeyCode::Char('W') => {
1376 self.save_state_snapshot(state);
1377 return Some(vec![]);
1378 }
1379 _ => {}
1380 }
1381
1382 let action = match key.code {
1384 KeyCode::Char('a') | KeyCode::Char('A') => Some(DebugAction::ToggleActionLog),
1385 KeyCode::Char('y') | KeyCode::Char('Y') => Some(DebugAction::CopyFrame),
1386 KeyCode::Char('i') | KeyCode::Char('I') => Some(DebugAction::ToggleMouseCapture),
1387 KeyCode::Char('q') | KeyCode::Char('Q') => Some(DebugAction::CloseOverlay),
1388 _ => None,
1389 };
1390
1391 if let Some(action) = action {
1392 let effect = self.handle_action(action);
1393 return Some(effect.into_iter().collect());
1394 }
1395
1396 match &self.freeze.overlay {
1398 Some(DebugOverlay::ActionLog(_)) => {
1399 let action = match key.code {
1400 KeyCode::Char('j') | KeyCode::Down => Some(DebugAction::ActionLogScrollDown),
1401 KeyCode::Char('k') | KeyCode::Up => Some(DebugAction::ActionLogScrollUp),
1402 KeyCode::Char('g') => Some(DebugAction::ActionLogScrollTop),
1403 KeyCode::Char('G') => Some(DebugAction::ActionLogScrollBottom),
1404 KeyCode::PageDown => Some(DebugAction::ActionLogPageDown),
1405 KeyCode::PageUp => Some(DebugAction::ActionLogPageUp),
1406 KeyCode::Enter => Some(DebugAction::ActionLogShowDetail),
1407 _ => None,
1408 };
1409 if let Some(action) = action {
1410 self.handle_action(action);
1411 return Some(vec![]);
1412 }
1413 }
1414 Some(DebugOverlay::State(_)) => {
1415 self.sync_state_tree_state();
1416 let nodes = &self.state_tree_nodes;
1417 let selected_id = self.state_tree_selected.as_ref();
1418 let expanded_ids = &self.state_tree_expanded;
1419 let style = self.state_tree_style();
1420 let props = Self::build_state_tree_props(nodes, selected_id, expanded_ids, style);
1421 let actions: Vec<_> = self
1422 .state_tree_view
1423 .handle_event(&EventKind::Key(key), props)
1424 .into_iter()
1425 .collect();
1426 if !actions.is_empty() {
1427 self.apply_state_tree_actions(actions);
1428 return Some(vec![]);
1429 }
1430 }
1431 Some(DebugOverlay::Inspect(table)) => {
1432 if self.handle_table_scroll_key(key.code, table.rows.len()) {
1433 return Some(vec![]);
1434 }
1435 }
1436 Some(DebugOverlay::ActionDetail(detail)) => {
1437 if matches!(key.code, KeyCode::Esc | KeyCode::Backspace | KeyCode::Enter) {
1439 self.handle_action(DebugAction::ActionLogBackToList);
1440 return Some(vec![]);
1441 }
1442
1443 let params_lines = self.detail_params_lines(detail);
1444 if self.handle_detail_scroll_key(key.code, params_lines.len()) {
1445 return Some(vec![]);
1446 }
1447 }
1448 _ => {}
1449 }
1450
1451 Some(vec![])
1453 }
1454
1455 fn toggle(&mut self) -> Option<DebugSideEffect<A>> {
1456 if self.freeze.enabled {
1457 #[cfg(feature = "subscriptions")]
1459 if let Some(ref handle) = self.sub_handle {
1460 handle.resume();
1461 }
1462
1463 #[cfg(feature = "tasks")]
1464 let task_queued = if let Some(ref handle) = self.task_handle {
1465 handle.resume()
1466 } else {
1467 vec![]
1468 };
1469 #[cfg(not(feature = "tasks"))]
1470 let task_queued: Vec<A> = vec![];
1471
1472 let queued = self.freeze.take_queued();
1473 self.freeze.disable();
1474 self.state_snapshot = None;
1475 self.state_tree_nodes.clear();
1476 self.state_tree_selected = None;
1477 self.state_tree_expanded.clear();
1478 self.state_tree_view = TreeView::new();
1479 self.table_scroll_offset = 0;
1480 self.table_page_size = 1;
1481 self.detail_scroll_offset = 0;
1482 self.detail_page_size = 1;
1483
1484 let mut all_queued = queued;
1486 all_queued.extend(task_queued);
1487
1488 if all_queued.is_empty() {
1489 None
1490 } else {
1491 Some(DebugSideEffect::ProcessQueuedActions(all_queued))
1492 }
1493 } else {
1494 #[cfg(feature = "tasks")]
1496 if let Some(ref handle) = self.task_handle {
1497 handle.pause();
1498 }
1499 #[cfg(feature = "subscriptions")]
1500 if let Some(ref handle) = self.sub_handle {
1501 handle.pause();
1502 }
1503 self.freeze.enable();
1504 self.state_snapshot = None;
1505 self.state_tree_nodes.clear();
1506 self.state_tree_selected = None;
1507 self.state_tree_expanded.clear();
1508 self.state_tree_view = TreeView::new();
1509 self.table_scroll_offset = 0;
1510 self.table_page_size = 1;
1511 self.detail_scroll_offset = 0;
1512 self.detail_page_size = 1;
1513 None
1514 }
1515 }
1516
1517 fn handle_action(&mut self, action: DebugAction) -> Option<DebugSideEffect<A>> {
1518 match action {
1519 DebugAction::Toggle => self.toggle(),
1520 DebugAction::CopyFrame => {
1521 let text = &self.freeze.snapshot_text;
1522 let encoded = BASE64_STANDARD.encode(text);
1524 print!("\x1b]52;c;{}\x07", encoded);
1525 std::io::stdout().flush().ok();
1526 self.freeze.set_message("Copied to clipboard");
1527 None
1528 }
1529 DebugAction::ToggleState => {
1530 if matches!(self.freeze.overlay, Some(DebugOverlay::State(_))) {
1531 self.freeze.clear_overlay();
1532 } else if let Some(ref table) = self.state_snapshot {
1533 self.set_state_overlay(table.clone());
1534 } else {
1535 let table = DebugTableBuilder::new()
1537 .section("State")
1538 .entry(
1539 "hint",
1540 "Press 's' after providing state via render_with_state() or show_state_overlay()",
1541 )
1542 .finish("Application State");
1543 self.freeze.set_overlay(DebugOverlay::State(table));
1544 }
1545 None
1546 }
1547 DebugAction::ToggleActionLog => {
1548 if matches!(self.freeze.overlay, Some(DebugOverlay::ActionLog(_))) {
1549 self.freeze.clear_overlay();
1550 } else {
1551 self.show_action_log();
1552 }
1553 None
1554 }
1555 DebugAction::ActionLogScrollUp => {
1556 if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
1557 log.scroll_up();
1558 }
1559 None
1560 }
1561 DebugAction::ActionLogScrollDown => {
1562 if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
1563 log.scroll_down();
1564 }
1565 None
1566 }
1567 DebugAction::ActionLogScrollTop => {
1568 if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
1569 log.scroll_to_top();
1570 }
1571 None
1572 }
1573 DebugAction::ActionLogScrollBottom => {
1574 if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
1575 log.scroll_to_bottom();
1576 }
1577 None
1578 }
1579 DebugAction::ActionLogPageUp => {
1580 if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
1581 log.page_up(10);
1582 }
1583 None
1584 }
1585 DebugAction::ActionLogPageDown => {
1586 if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
1587 log.page_down(10);
1588 }
1589 None
1590 }
1591 DebugAction::ActionLogShowDetail => {
1592 if let Some(DebugOverlay::ActionLog(ref log)) = self.freeze.overlay {
1593 if let Some(detail) = log.selected_detail() {
1594 self.detail_scroll_offset = 0;
1595 self.detail_page_size = 1;
1596 self.freeze.set_overlay(DebugOverlay::ActionDetail(detail));
1597 }
1598 }
1599 None
1600 }
1601 DebugAction::ActionLogBackToList => {
1602 if matches!(self.freeze.overlay, Some(DebugOverlay::ActionDetail(_))) {
1604 self.show_action_log();
1605 }
1606 None
1607 }
1608 DebugAction::ToggleMouseCapture => {
1609 self.freeze.toggle_mouse_capture();
1610 None
1611 }
1612 DebugAction::InspectCell { column, row } => {
1613 if let Some(ref snapshot) = self.freeze.snapshot {
1614 let overlay = self.build_inspect_overlay(column, row, snapshot);
1615 self.table_scroll_offset = 0;
1616 self.freeze.set_overlay(DebugOverlay::Inspect(overlay));
1617 }
1618 self.freeze.mouse_capture_enabled = false;
1619 None
1620 }
1621 DebugAction::CloseOverlay => {
1622 self.freeze.clear_overlay();
1623 None
1624 }
1625 DebugAction::RequestCapture => {
1626 self.freeze.request_capture();
1627 None
1628 }
1629 }
1630 }
1631
1632 fn split_for_banner(&self, area: Rect) -> (Rect, Rect) {
1633 let banner_height = area.height.min(1);
1634 match self.banner_position {
1635 BannerPosition::Bottom => {
1636 let app_area = Rect {
1637 height: area.height.saturating_sub(banner_height),
1638 ..area
1639 };
1640 let banner_area = Rect {
1641 y: area.y.saturating_add(app_area.height),
1642 height: banner_height,
1643 ..area
1644 };
1645 (app_area, banner_area)
1646 }
1647 BannerPosition::Top => {
1648 let banner_area = Rect {
1649 y: area.y,
1650 height: banner_height,
1651 ..area
1652 };
1653 let app_area = Rect {
1654 y: area.y.saturating_add(banner_height),
1655 height: area.height.saturating_sub(banner_height),
1656 ..area
1657 };
1658 (app_area, banner_area)
1659 }
1660 }
1661 }
1662
1663 fn render_debug_overlay(&mut self, frame: &mut Frame, app_area: Rect, banner_area: Rect) {
1664 let overlay = self.freeze.overlay.clone();
1665
1666 if let Some(ref overlay) = overlay {
1668 dim_buffer(frame.buffer_mut(), self.style.dim_factor);
1669
1670 match overlay {
1671 DebugOverlay::Inspect(table) => {
1672 self.render_table_modal(frame, app_area, table);
1673 }
1674 DebugOverlay::State(table) => {
1675 self.render_state_tree_modal(frame, app_area, table);
1676 }
1677 DebugOverlay::ActionLog(log) => {
1678 self.render_action_log_modal(frame, app_area, log);
1679 }
1680 DebugOverlay::ActionDetail(detail) => {
1681 self.render_action_detail_modal(frame, app_area, detail);
1682 }
1683 }
1684 }
1685
1686 self.render_banner(frame, banner_area);
1688 }
1689
1690 fn render_banner(&self, frame: &mut Frame, banner_area: Rect) {
1691 if banner_area.height == 0 {
1692 return;
1693 }
1694
1695 use ratatui::text::Span;
1696
1697 let keys = &self.style.key_styles;
1698 let toggle_key_str = format_key(self.toggle_key);
1699 let label_style = self.style.label_style;
1700 let value_style = self.style.value_style;
1701
1702 let mut left_items: Vec<StatusBarItem<'static>> = Vec::new();
1703 left_items.push(StatusBarItem::span(Span::styled(
1704 " DEBUG ",
1705 self.style.title_style,
1706 )));
1707 left_items.push(StatusBarItem::text(" "));
1708
1709 let push_item =
1710 |items: &mut Vec<StatusBarItem<'static>>, key: &str, label: &str, key_style: Style| {
1711 items.push(StatusBarItem::span(Span::styled(
1712 format!(" {key} "),
1713 key_style,
1714 )));
1715 items.push(StatusBarItem::span(Span::styled(
1716 format!(" {label} "),
1717 label_style,
1718 )));
1719 };
1720
1721 push_item(&mut left_items, &toggle_key_str, "resume", keys.toggle);
1722 push_item(&mut left_items, "a", "actions", keys.actions);
1723 push_item(&mut left_items, "s", "state", keys.state);
1724 push_item(&mut left_items, "w", "save", keys.state);
1725 push_item(
1726 &mut left_items,
1727 "b",
1728 self.banner_position.label(),
1729 keys.actions,
1730 );
1731 push_item(&mut left_items, "y", "copy", keys.copy);
1732
1733 if self.freeze.mouse_capture_enabled {
1734 push_item(&mut left_items, "click", "inspect", keys.mouse);
1735 } else {
1736 push_item(&mut left_items, "i", "mouse", keys.mouse);
1737 }
1738
1739 let mut right_items: Vec<StatusBarItem<'static>> = Vec::new();
1740 if let Some(ref msg) = self.freeze.message {
1741 right_items.push(StatusBarItem::span(Span::styled(
1742 format!(" {msg} "),
1743 value_style,
1744 )));
1745 }
1746
1747 let style = StatusBarStyle {
1748 base: BaseStyle {
1749 border: None,
1750 padding: Padding::default(),
1751 bg: self.style.banner_bg.bg,
1752 fg: None,
1753 },
1754 text: label_style,
1755 hint_key: keys.toggle,
1756 hint_label: label_style,
1757 separator: label_style,
1758 };
1759
1760 let left = StatusBarSection::items(&left_items).with_separator("");
1761 let right = if right_items.is_empty() {
1762 StatusBarSection::empty()
1763 } else {
1764 StatusBarSection::items(&right_items).with_separator("")
1765 };
1766
1767 let mut status_bar = StatusBar::new();
1768 <StatusBar as tui_dispatch_core::Component<()>>::render(
1769 &mut status_bar,
1770 frame,
1771 banner_area,
1772 StatusBarProps {
1773 left,
1774 center: StatusBarSection::empty(),
1775 right,
1776 style,
1777 is_focused: false,
1778 },
1779 );
1780 }
1781
1782 fn render_state_tree_modal(
1783 &mut self,
1784 frame: &mut Frame,
1785 app_area: Rect,
1786 table: &DebugTableOverlay,
1787 ) {
1788 self.sync_state_tree_state();
1789
1790 let modal_area = self.overlay_modal_area(app_area);
1792 let modal_style = self.overlay_modal_style();
1793 let title = &table.title;
1794
1795 let mut modal = Modal::new();
1796 let mut render_content = |frame: &mut Frame, content_area: Rect| {
1797 if content_area.height == 0 || content_area.width == 0 {
1798 return;
1799 }
1800
1801 let content_area = self.render_overlay_title(frame, content_area, title);
1802 if content_area.height == 0 || content_area.width == 0 {
1803 return;
1804 }
1805
1806 let nodes = &self.state_tree_nodes;
1807 let selected_id = self.state_tree_selected.as_ref();
1808 let expanded_ids = &self.state_tree_expanded;
1809 let style = self.state_tree_style();
1810 let props = Self::build_state_tree_props(nodes, selected_id, expanded_ids, style);
1811
1812 <TreeView<String> as tui_dispatch_core::Component<StateTreeAction>>::render(
1813 &mut self.state_tree_view,
1814 frame,
1815 content_area,
1816 props,
1817 );
1818 };
1819
1820 modal.render(
1821 frame,
1822 app_area,
1823 ModalProps {
1824 is_open: true,
1825 is_focused: false,
1826 area: modal_area,
1827 style: modal_style,
1828 behavior: ModalBehavior::default(),
1829 on_close: || (),
1830 render_content: &mut render_content,
1831 },
1832 );
1833 }
1834
1835 fn render_table_modal(&mut self, frame: &mut Frame, app_area: Rect, table: &DebugTableOverlay) {
1836 let scrollbar_style = self.component_scrollbar_style();
1837 let table_scroll_offset = self.table_scroll_offset;
1838
1839 self.render_overlay_container(frame, app_area, &table.title, |frame, content_area| {
1840 let mut table_area = content_area;
1841 if let Some(ref preview) = table.cell_preview {
1842 if table_area.height > 1 {
1843 let preview_area = Rect {
1844 height: 1,
1845 ..table_area
1846 };
1847 let preview_widget = CellPreviewWidget::new(preview)
1848 .label_style(Style::default().fg(DebugStyle::text_secondary()))
1849 .value_style(Style::default().fg(DebugStyle::text_primary()));
1850 frame.render_widget(preview_widget, preview_area);
1851
1852 table_area = Rect {
1853 y: table_area.y.saturating_add(1),
1854 height: table_area.height.saturating_sub(1),
1855 ..table_area
1856 };
1857 }
1858 }
1859
1860 if table_area.height == 0 || table_area.width == 0 {
1861 return;
1862 }
1863
1864 use ratatui::text::{Line, Span};
1865 use ratatui::widgets::Paragraph;
1866
1867 let header_area = Rect {
1868 height: 1,
1869 ..table_area
1870 };
1871 let rows_area = Rect {
1872 y: table_area.y.saturating_add(1),
1873 height: table_area.height.saturating_sub(1),
1874 ..table_area
1875 };
1876
1877 let style = DebugTableStyle::default();
1878 let show_scrollbar = rows_area.height > 0
1879 && table.rows.len() > rows_area.height as usize
1880 && table_area.width > 1;
1881 let text_width = if show_scrollbar {
1882 table_area.width.saturating_sub(1)
1883 } else {
1884 table_area.width
1885 } as usize;
1886
1887 if text_width == 0 {
1888 return;
1889 }
1890
1891 let max_key_len = table
1892 .rows
1893 .iter()
1894 .filter_map(|row| match row {
1895 super::table::DebugTableRow::Entry { key, .. } => Some(key.chars().count()),
1896 super::table::DebugTableRow::Section(_) => None,
1897 })
1898 .max()
1899 .unwrap_or(0);
1900 let max_label = text_width.saturating_sub(8).max(10);
1901 let label_width = (max_key_len + 2).clamp(12, 30).min(max_label);
1902 let value_width = text_width.saturating_sub(label_width + 2).max(1);
1903
1904 let header_line = Line::from(vec![
1905 Span::styled(pad_text("Field", label_width), style.header),
1906 Span::styled(" ", style.header),
1907 Span::styled(pad_text("Value", value_width), style.header),
1908 ]);
1909 frame.render_widget(Paragraph::new(header_line), header_area);
1910
1911 if rows_area.height == 0 {
1912 return;
1913 }
1914
1915 let ron_style = RonSyntaxStyle::with_base(style.value);
1916 let mut rows = Vec::new();
1917 let mut entry_index = 0usize;
1918 for row in table.rows.iter() {
1919 match row {
1920 super::table::DebugTableRow::Section(title) => {
1921 entry_index = 0;
1922 let mut text = format!(" {title} ");
1923 text = truncate_with_ellipsis(&text, text_width);
1924 text = pad_text(&text, text_width);
1925 rows.push(Line::from(vec![Span::styled(text, style.section)]));
1926 }
1927 super::table::DebugTableRow::Entry { key, value } => {
1928 let row_style = if entry_index % 2 == 0 {
1929 style.row_styles.0
1930 } else {
1931 style.row_styles.1
1932 };
1933 entry_index = entry_index.saturating_add(1);
1934 let key_text = pad_text(key, label_width);
1935 let mut value_text = truncate_with_ellipsis(value, value_width);
1936 value_text = pad_text(&value_text, value_width);
1937
1938 let mut value_spans: Vec<Span<'static>> =
1939 ron_spans(&value_text, &ron_style)
1940 .into_iter()
1941 .map(|span| span.patch_style(row_style))
1942 .collect();
1943
1944 let mut spans = vec![
1945 Span::styled(key_text, style.key).patch_style(row_style),
1946 Span::styled(" ", row_style),
1947 ];
1948 spans.append(&mut value_spans);
1949
1950 rows.push(Line::from(spans));
1951 }
1952 }
1953 }
1954
1955 let scroll_style = ScrollViewStyle {
1957 base: BaseStyle {
1958 border: None,
1959 padding: Padding::default(),
1960 bg: None,
1961 fg: None,
1962 },
1963 scrollbar: scrollbar_style.clone(),
1964 };
1965 let scroller = LinesScroller::new(&rows);
1966 let mut scroll_view = ScrollView::new();
1967 <ScrollView as tui_dispatch_core::Component<()>>::render(
1968 &mut scroll_view,
1969 frame,
1970 rows_area,
1971 ScrollViewProps {
1972 content_height: scroller.content_height(),
1973 scroll_offset: table_scroll_offset,
1974 is_focused: true,
1975 style: scroll_style,
1976 behavior: ScrollViewBehavior::default(),
1977 on_scroll: |_| (),
1978 render_content: &mut scroller.renderer(),
1979 },
1980 );
1981 });
1982 }
1983
1984 fn render_action_log_modal(&self, frame: &mut Frame, app_area: Rect, log: &ActionLogOverlay) {
1985 let entry_count = log.entries.len();
1986 let title = if entry_count > 0 {
1987 format!("{} ({} entries)", log.title, entry_count)
1988 } else {
1989 format!("{} (empty)", log.title)
1990 };
1991
1992 self.render_overlay_container(frame, app_area, &title, |frame, log_area| {
1993 use ratatui::text::{Line, Span};
1994 use ratatui::widgets::Paragraph;
1995
1996 let style = ActionLogStyle::default();
1997 let spacing = 1usize;
1998 let seq_width = 5usize;
1999 let name_width = 20usize;
2000 let elapsed_width = 8usize;
2001
2002 let header_area = Rect {
2003 height: 1,
2004 ..log_area
2005 };
2006 let body_area = Rect {
2007 y: log_area.y.saturating_add(1),
2008 height: log_area.height.saturating_sub(1),
2009 ..log_area
2010 };
2011
2012 let show_scrollbar = body_area.height > 0
2013 && log.entries.len() > body_area.height as usize
2014 && log_area.width > 1;
2015 let text_width = if show_scrollbar {
2016 log_area.width.saturating_sub(1)
2017 } else {
2018 log_area.width
2019 } as usize;
2020 let params_width = text_width
2021 .saturating_sub(seq_width + name_width + elapsed_width + spacing * 3)
2022 .max(1);
2023
2024 let header_line = Line::from(vec![
2025 Span::styled(pad_text("#", seq_width), style.header),
2026 Span::styled(" ", style.header),
2027 Span::styled(pad_text("Action", name_width), style.header),
2028 Span::styled(" ", style.header),
2029 Span::styled(pad_text("Params", params_width), style.header),
2030 Span::styled(" ", style.header),
2031 Span::styled(pad_text("Elapsed", elapsed_width), style.header),
2032 ]);
2033 frame.render_widget(Paragraph::new(header_line), header_area);
2034
2035 if body_area.height == 0 {
2036 return;
2037 }
2038
2039 let ron_style = RonSyntaxStyle::with_base(style.params);
2040 let rows: Vec<Line> = log
2041 .entries
2042 .iter()
2043 .enumerate()
2044 .map(|(idx, entry)| {
2045 let row_style = if idx == log.selected {
2046 style.selected
2047 } else if idx % 2 == 0 {
2048 style.row_styles.0
2049 } else {
2050 style.row_styles.1
2051 };
2052
2053 let seq_text = pad_text(&entry.sequence.to_string(), seq_width);
2054 let name_text = pad_text(&entry.name, name_width);
2055 let elapsed_text = pad_text(&entry.elapsed, elapsed_width);
2056
2057 let params_compact = entry.params.replace('\n', " ");
2058 let params_compact = params_compact
2059 .split_whitespace()
2060 .collect::<Vec<_>>()
2061 .join(" ");
2062 let params_trimmed = truncate_with_ellipsis(¶ms_compact, params_width);
2063 let params_text = pad_text(¶ms_trimmed, params_width);
2064 let mut params_spans: Vec<Span<'static>> = ron_spans(¶ms_text, &ron_style)
2065 .into_iter()
2066 .map(|span| span.patch_style(row_style))
2067 .collect();
2068
2069 let mut spans = vec![
2070 Span::styled(seq_text, style.sequence).patch_style(row_style),
2071 Span::styled(" ", row_style),
2072 Span::styled(name_text, style.name).patch_style(row_style),
2073 Span::styled(" ", row_style),
2074 ];
2075 spans.append(&mut params_spans);
2076 spans.push(Span::styled(" ", row_style));
2077 spans.push(Span::styled(elapsed_text, style.elapsed).patch_style(row_style));
2078
2079 Line::from(spans)
2080 })
2081 .collect();
2082
2083 let scroll_style = ScrollViewStyle {
2084 base: BaseStyle {
2085 border: None,
2086 padding: Padding::default(),
2087 bg: None,
2088 fg: None,
2089 },
2090 scrollbar: self.component_scrollbar_style(),
2091 };
2092 let scroll_offset = log.scroll_offset_for(body_area.height as usize);
2093 let scroller = LinesScroller::new(&rows);
2094 let mut scroll_view = ScrollView::new();
2095 <ScrollView as tui_dispatch_core::Component<()>>::render(
2096 &mut scroll_view,
2097 frame,
2098 body_area,
2099 ScrollViewProps {
2100 content_height: log.entries.len(),
2101 scroll_offset,
2102 is_focused: true,
2103 style: scroll_style,
2104 behavior: ScrollViewBehavior::default(),
2105 on_scroll: |_| (),
2106 render_content: &mut scroller.renderer(),
2107 },
2108 );
2109 });
2110 }
2111
2112 fn render_action_detail_modal(
2113 &mut self,
2114 frame: &mut Frame,
2115 app_area: Rect,
2116 detail: &super::table::ActionDetailOverlay,
2117 ) {
2118 let title = format!("Action #{} - {}", detail.sequence, detail.name);
2119 let param_lines = self.detail_params_lines(detail);
2120 let scrollbar_style = self.component_scrollbar_style();
2121 let detail_scroll_offset = self.detail_scroll_offset;
2122 let detail_page_size = self.detail_page_size;
2123
2124 self.render_overlay_container(frame, app_area, &title, |frame, detail_area| {
2125 use ratatui::text::{Line, Span};
2126 use ratatui::widgets::Paragraph;
2127
2128 let label_style = Style::default().fg(DebugStyle::text_secondary());
2129 let value_style = Style::default().fg(DebugStyle::text_primary());
2130
2131 let header_lines = vec![
2132 Line::from(vec![
2133 Span::styled("Name: ", label_style),
2134 Span::styled(&detail.name, value_style),
2135 ]),
2136 Line::from(vec![
2137 Span::styled("Sequence: ", label_style),
2138 Span::styled(detail.sequence.to_string(), value_style),
2139 ]),
2140 Line::from(vec![
2141 Span::styled("Elapsed: ", label_style),
2142 Span::styled(&detail.elapsed, value_style),
2143 ]),
2144 Line::from(""),
2145 Line::from(Span::styled("Parameters:", label_style)),
2146 ];
2147
2148 let mut param_lines = param_lines.clone();
2149 if param_lines.is_empty() {
2150 param_lines.push(Line::from(Span::styled(" (none)", value_style)));
2151 }
2152 let param_lines_len = param_lines.len();
2153
2154 let footer_lines = vec![
2155 Line::from(""),
2156 Line::from(Span::styled(
2157 "Press Enter/Esc/Backspace to go back",
2158 label_style,
2159 )),
2160 ];
2161
2162 let header_height = header_lines.len() as u16;
2163 let footer_height = footer_lines.len() as u16;
2164
2165 let header_area_height = header_height.min(detail_area.height);
2166 let header_area = Rect {
2167 height: header_area_height,
2168 ..detail_area
2169 };
2170 if header_area.height > 0 {
2171 let paragraph = Paragraph::new(header_lines);
2172 frame.render_widget(paragraph, header_area);
2173 }
2174
2175 let footer_area_height =
2176 footer_height.min(detail_area.height.saturating_sub(header_area_height));
2177 let footer_area = Rect {
2178 x: detail_area.x,
2179 y: detail_area
2180 .y
2181 .saturating_add(detail_area.height.saturating_sub(footer_area_height)),
2182 width: detail_area.width,
2183 height: footer_area_height,
2184 };
2185 if footer_area.height > 0 {
2186 let paragraph = Paragraph::new(footer_lines);
2187 frame.render_widget(paragraph, footer_area);
2188 }
2189
2190 let params_area = Rect {
2191 x: detail_area.x,
2192 y: detail_area.y.saturating_add(header_area_height),
2193 width: detail_area.width,
2194 height: detail_area
2195 .height
2196 .saturating_sub(header_area_height + footer_area_height),
2197 };
2198
2199 let max_offset = param_lines_len.saturating_sub(detail_page_size.max(1));
2201 let current_scroll_offset = detail_scroll_offset.min(max_offset);
2202
2203 if params_area.height > 0 {
2204 let scroll_style = ScrollViewStyle {
2205 base: BaseStyle {
2206 border: None,
2207 padding: Padding::default(),
2208 bg: Some(DebugStyle::overlay_bg_dark()),
2209 fg: None,
2210 },
2211 scrollbar: scrollbar_style.clone(),
2212 };
2213
2214 let scroller = LinesScroller::new(¶m_lines);
2215 let mut scroll_view = ScrollView::new();
2216 <ScrollView as tui_dispatch_core::Component<()>>::render(
2217 &mut scroll_view,
2218 frame,
2219 params_area,
2220 ScrollViewProps {
2221 content_height: param_lines_len,
2222 scroll_offset: current_scroll_offset,
2223 is_focused: true,
2224 style: scroll_style,
2225 behavior: ScrollViewBehavior::default(),
2226 on_scroll: |_| (),
2227 render_content: &mut scroller.renderer(),
2228 },
2229 );
2230 }
2231 });
2232 }
2233
2234 fn detail_params_lines(
2235 &self,
2236 detail: &super::table::ActionDetailOverlay,
2237 ) -> Vec<ratatui::text::Line<'static>> {
2238 use ratatui::text::{Line, Span};
2239
2240 if detail.params.is_empty() {
2241 return Vec::new();
2242 }
2243
2244 let value_style = Style::default().fg(DebugStyle::text_primary());
2245 let ron_style = RonSyntaxStyle::with_base(value_style);
2246
2247 detail
2248 .params
2249 .lines()
2250 .map(|line| {
2251 let mut spans = vec![Span::styled(" ", value_style)];
2252 spans.extend(ron_spans(line, &ron_style));
2253 Line::from(spans)
2254 })
2255 .collect()
2256 }
2257
2258 fn build_inspect_overlay(&self, column: u16, row: u16, snapshot: &Buffer) -> DebugTableOverlay {
2259 let mut builder = DebugTableBuilder::new();
2260
2261 builder.push_section("Position");
2262 builder.push_entry("column", column.to_string());
2263 builder.push_entry("row", row.to_string());
2264
2265 if let Some(preview) = inspect_cell(snapshot, column, row) {
2266 builder.set_cell_preview(preview);
2267 }
2268
2269 builder.finish(format!("Inspect ({column}, {row})"))
2270 }
2271}
2272
2273#[derive(Debug, Serialize)]
2274struct DebugStateSnapshot {
2275 title: String,
2276 sections: Vec<DebugStateSnapshotSection>,
2277}
2278
2279#[derive(Debug, Serialize)]
2280struct DebugStateSnapshotSection {
2281 title: String,
2282 entries: Vec<DebugStateSnapshotEntry>,
2283}
2284
2285#[derive(Debug, Serialize)]
2286struct DebugStateSnapshotEntry {
2287 key: String,
2288 value: String,
2289}
2290
2291impl DebugStateSnapshot {
2292 fn from_state<S: DebugState>(state: &S, title: &str) -> Self {
2293 let sections = state
2294 .debug_sections()
2295 .into_iter()
2296 .map(|section| DebugStateSnapshotSection {
2297 title: section.title,
2298 entries: section
2299 .entries
2300 .into_iter()
2301 .map(|entry| DebugStateSnapshotEntry {
2302 key: entry.key,
2303 value: entry.value,
2304 })
2305 .collect(),
2306 })
2307 .collect();
2308
2309 Self {
2310 title: title.to_string(),
2311 sections,
2312 }
2313 }
2314}
2315
2316fn pad_text(value: &str, width: usize) -> String {
2317 if width == 0 {
2318 return String::new();
2319 }
2320 let mut text: String = value.chars().take(width).collect();
2321 let len = text.chars().count();
2322 if len < width {
2323 text.push_str(&" ".repeat(width - len));
2324 }
2325 text
2326}
2327
2328fn truncate_with_ellipsis(value: &str, width: usize) -> String {
2329 if width == 0 {
2330 return String::new();
2331 }
2332 let count = value.chars().count();
2333 if count <= width {
2334 return value.to_string();
2335 }
2336 if width <= 3 {
2337 return value.chars().take(width).collect();
2338 }
2339 let mut text: String = value.chars().take(width - 3).collect();
2340 text.push_str("...");
2341 text
2342}
2343
2344fn format_key(key: KeyCode) -> String {
2346 match key {
2347 KeyCode::F(n) => format!("F{}", n),
2348 KeyCode::Char(c) => c.to_string(),
2349 KeyCode::Esc => "Esc".to_string(),
2350 KeyCode::Enter => "Enter".to_string(),
2351 KeyCode::Tab => "Tab".to_string(),
2352 KeyCode::Backspace => "Bksp".to_string(),
2353 KeyCode::Delete => "Del".to_string(),
2354 KeyCode::Up => "↑".to_string(),
2355 KeyCode::Down => "↓".to_string(),
2356 KeyCode::Left => "←".to_string(),
2357 KeyCode::Right => "→".to_string(),
2358 _ => format!("{:?}", key),
2359 }
2360}
2361
2362#[cfg(test)]
2363mod tests {
2364 use super::*;
2365
2366 #[derive(Debug, Clone)]
2367 enum TestAction {
2368 Foo,
2369 Bar,
2370 }
2371
2372 impl tui_dispatch_core::Action for TestAction {
2373 fn name(&self) -> &'static str {
2374 match self {
2375 TestAction::Foo => "Foo",
2376 TestAction::Bar => "Bar",
2377 }
2378 }
2379 }
2380
2381 impl tui_dispatch_core::ActionParams for TestAction {
2382 fn params(&self) -> String {
2383 String::new()
2384 }
2385 }
2386
2387 #[test]
2388 fn test_debug_layer_creation() {
2389 let layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2390 assert!(!layer.is_enabled());
2391 assert!(layer.freeze().snapshot.is_none());
2392 }
2393
2394 #[test]
2395 fn test_toggle() {
2396 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2397
2398 let effect = layer.toggle();
2400 assert!(effect.is_none());
2401 assert!(layer.is_enabled());
2402
2403 let effect = layer.toggle();
2405 assert!(effect.is_none()); assert!(!layer.is_enabled());
2407 }
2408
2409 #[test]
2410 fn test_set_enabled() {
2411 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2412
2413 layer.set_enabled(true);
2414 assert!(layer.is_enabled());
2415
2416 layer.set_enabled(false);
2417 assert!(!layer.is_enabled());
2418 }
2419
2420 #[test]
2421 fn test_simple_constructor() {
2422 let layer: DebugLayer<TestAction> = DebugLayer::simple();
2423 assert!(!layer.is_enabled());
2424 }
2425
2426 #[test]
2427 fn test_queued_actions_returned_on_disable() {
2428 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2429
2430 layer.toggle(); layer.queue_action(TestAction::Foo);
2432 layer.queue_action(TestAction::Bar);
2433
2434 let effect = layer.toggle(); match effect {
2437 Some(DebugSideEffect::ProcessQueuedActions(actions)) => {
2438 assert_eq!(actions.len(), 2);
2439 }
2440 _ => panic!("Expected ProcessQueuedActions"),
2441 }
2442 }
2443
2444 #[test]
2445 fn test_split_area() {
2446 let layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2447
2448 let area = Rect::new(0, 0, 80, 24);
2450 let (app, banner) = layer.split_area(area);
2451 assert_eq!(app, area);
2452 assert_eq!(banner, Rect::ZERO);
2453 }
2454
2455 #[test]
2456 fn test_split_area_enabled() {
2457 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2458 layer.toggle();
2459
2460 let area = Rect::new(0, 0, 80, 24);
2461 let (app, banner) = layer.split_area(area);
2462
2463 assert_eq!(app.height, 23);
2464 assert_eq!(banner.height, 1);
2465 assert_eq!(banner.y, 23);
2466 }
2467
2468 #[test]
2469 fn test_split_area_enabled_top() {
2470 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2471 layer.toggle();
2472 layer.set_banner_position(BannerPosition::Top);
2473
2474 let area = Rect::new(0, 0, 80, 24);
2475 let (app, banner) = layer.split_area(area);
2476
2477 assert_eq!(banner.y, 0);
2478 assert_eq!(banner.height, 1);
2479 assert_eq!(app.y, 1);
2480 assert_eq!(app.height, 23);
2481 }
2482
2483 #[test]
2484 fn test_inactive_layer() {
2485 let layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12)).active(false);
2486
2487 assert!(!layer.is_active());
2488 assert!(!layer.is_enabled());
2489 }
2490
2491 #[test]
2492 fn test_action_log() {
2493 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2494
2495 layer.log_action(&TestAction::Foo);
2496 layer.log_action(&TestAction::Bar);
2497
2498 assert_eq!(layer.action_log().entries().count(), 2);
2499 }
2500}