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