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, KeyModifiers, 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, ActionLoggerConfig};
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 ModalHint::new("/", "filter"),
263 ],
264 right: &[
265 ModalHint::new("n/N", "next/prev"),
266 ModalHint::new("Enter", "details"),
267 ModalHint::new("Bksp", "close"),
268 ],
269};
270
271const ACTION_LOG_SEARCH_INPUT_HINTS: ModalHints = ModalHints {
273 left: &[
274 ModalHint::new("type", "query"),
275 ModalHint::new("Bksp", "edit"),
276 ],
277 right: &[
278 ModalHint::new("Enter", "done"),
279 ModalHint::new("Esc", "done"),
280 ],
281};
282
283const STATE_TREE_HINTS: ModalHints = ModalHints {
285 left: &[
286 ModalHint::new("j/k", "scroll"),
287 ModalHint::new("Space", "expand"),
288 ],
289 right: &[ModalHint::new("w", "save"), ModalHint::new("Bksp", "close")],
290};
291
292const ACTION_DETAIL_HINTS: ModalHints = ModalHints {
294 left: &[ModalHint::new("j/k", "scroll")],
295 right: &[ModalHint::new("Bksp", "back")],
296};
297
298const INSPECT_HINTS: ModalHints = ModalHints {
300 left: &[ModalHint::new("j/k", "scroll")],
301 right: &[ModalHint::new("Bksp", "close")],
302};
303
304pub struct DebugOutcome<A> {
306 pub consumed: bool,
308 pub queued_actions: Vec<A>,
310 pub needs_render: bool,
312}
313
314impl<A> DebugOutcome<A> {
315 fn ignored() -> Self {
316 Self {
317 consumed: false,
318 queued_actions: Vec::new(),
319 needs_render: false,
320 }
321 }
322
323 fn consumed(queued_actions: Vec<A>) -> Self {
324 Self {
325 consumed: true,
326 queued_actions,
327 needs_render: true,
328 }
329 }
330
331 pub fn dispatch_queued<F>(self, mut dispatch: F) -> Option<bool>
335 where
336 F: FnMut(A),
337 {
338 if !self.consumed {
339 return None;
340 }
341
342 for action in self.queued_actions {
343 dispatch(action);
344 }
345
346 Some(self.needs_render)
347 }
348}
349
350impl<A> Default for DebugOutcome<A> {
351 fn default() -> Self {
352 Self::ignored()
353 }
354}
355
356pub struct DebugLayer<A> {
385 toggle_key: KeyCode,
387 freeze: DebugFreeze<A>,
389 banner_position: BannerPosition,
391 style: DebugStyle,
393 active: bool,
395 action_log: ActionLog,
397 state_snapshot: Option<DebugTableOverlay>,
399 state_snapshotter: Option<StateSnapshotter>,
401 state_tree_view: TreeView<String>,
403 state_tree_nodes: Vec<TreeNode<String, String>>,
405 state_tree_selected: Option<String>,
407 state_tree_expanded: HashSet<String>,
409 table_scroll_offset: usize,
411 table_page_size: usize,
413 detail_scroll_offset: usize,
415 detail_page_size: usize,
417 #[cfg(feature = "tasks")]
419 task_handle: Option<TaskPauseHandle<A>>,
420 #[cfg(feature = "subscriptions")]
422 sub_handle: Option<SubPauseHandle>,
423}
424
425impl<A> std::fmt::Debug for DebugLayer<A> {
426 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
427 f.debug_struct("DebugLayer")
428 .field("toggle_key", &self.toggle_key)
429 .field("active", &self.active)
430 .field("enabled", &self.freeze.enabled)
431 .field("has_snapshot", &self.freeze.snapshot.is_some())
432 .field("has_state_snapshot", &self.state_snapshot.is_some())
433 .field("has_state_snapshotter", &self.state_snapshotter.is_some())
434 .field("state_tree_nodes", &self.state_tree_nodes.len())
435 .field("state_tree_selected", &self.state_tree_selected)
436 .field("banner_position", &self.banner_position)
437 .field("table_scroll_offset", &self.table_scroll_offset)
438 .field("detail_scroll_offset", &self.detail_scroll_offset)
439 .field("queued_actions", &self.freeze.queued_actions.len())
440 .finish()
441 }
442}
443
444impl<A: Action> DebugLayer<A> {
445 pub fn new(toggle_key: KeyCode) -> Self {
455 Self {
456 toggle_key,
457 freeze: DebugFreeze::new(),
458 banner_position: BannerPosition::Bottom,
459 style: DebugStyle::default(),
460 active: true,
461 action_log: ActionLog::new(ActionLogConfig::with_capacity(100)),
462 state_snapshot: None,
463 state_snapshotter: None,
464 state_tree_view: TreeView::new(),
465 state_tree_nodes: Vec::new(),
466 state_tree_selected: None,
467 state_tree_expanded: HashSet::new(),
468 table_scroll_offset: 0,
469 table_page_size: 1,
470 detail_scroll_offset: 0,
471 detail_page_size: 1,
472 #[cfg(feature = "tasks")]
473 task_handle: None,
474 #[cfg(feature = "subscriptions")]
475 sub_handle: None,
476 }
477 }
478
479 pub fn simple() -> Self {
481 Self::new(KeyCode::F(12))
482 }
483
484 pub fn simple_with_toggle_key(toggle_key: KeyCode) -> Self {
486 Self::new(toggle_key)
487 }
488
489 pub fn active(mut self, active: bool) -> Self {
493 self.active = active;
494 self
495 }
496
497 pub fn with_banner_position(mut self, position: BannerPosition) -> Self {
499 self.banner_position = position;
500 self
501 }
502
503 #[cfg(feature = "tasks")]
508 pub fn with_task_manager(mut self, tasks: &tui_dispatch_core::tasks::TaskManager<A>) -> Self {
509 self.task_handle = Some(tasks.pause_handle());
510 self
511 }
512
513 #[cfg(feature = "subscriptions")]
517 pub fn with_subscriptions(
518 mut self,
519 subs: &tui_dispatch_core::subscriptions::Subscriptions<A>,
520 ) -> Self {
521 self.sub_handle = Some(subs.pause_handle());
522 self
523 }
524
525 pub fn with_action_log_capacity(mut self, capacity: usize) -> Self {
527 self.action_log = ActionLog::new(ActionLogConfig::with_capacity(capacity));
528 self
529 }
530
531 pub fn with_action_log_config(mut self, config: ActionLogConfig) -> Self {
533 self.action_log = ActionLog::new(config);
534 self
535 }
536
537 pub fn with_action_log_filter(mut self, filter: ActionLoggerConfig) -> Self {
539 let capacity = self.action_log.config().capacity;
540 self.action_log = ActionLog::new(ActionLogConfig::new(capacity, filter));
541 self
542 }
543
544 pub fn with_style(mut self, style: DebugStyle) -> Self {
546 self.style = style;
547 self
548 }
549
550 pub fn with_state_snapshotter<S, F>(mut self, snapshotter: F) -> Self
552 where
553 S: DebugState + 'static,
554 F: Fn(&S, &Path) -> crate::SnapshotResult<()> + 'static,
555 {
556 self.state_snapshotter = Some(Box::new(move |state, path| {
557 let state = state.downcast_ref::<S>().ok_or_else(|| {
558 crate::SnapshotError::Io(io::Error::new(
559 io::ErrorKind::InvalidInput,
560 "debug state snapshot type mismatch",
561 ))
562 })?;
563 snapshotter(state, path)
564 }));
565 self
566 }
567
568 pub fn with_state_snapshots<S>(self) -> Self
570 where
571 S: DebugState + Serialize + 'static,
572 {
573 self.with_state_snapshotter::<S, _>(|state, path| crate::save_json(path, state))
574 }
575
576 pub fn is_active(&self) -> bool {
578 self.active
579 }
580
581 pub fn is_enabled(&self) -> bool {
583 self.active && self.freeze.enabled
584 }
585
586 pub fn toggle_enabled(&mut self) -> Option<DebugSideEffect<A>> {
590 if !self.active {
591 return None;
592 }
593 self.toggle()
594 }
595
596 pub fn set_enabled(&mut self, enabled: bool) -> Option<DebugSideEffect<A>> {
600 if !self.active || enabled == self.freeze.enabled {
601 return None;
602 }
603 self.toggle()
604 }
605
606 pub fn set_banner_position(&mut self, position: BannerPosition) {
608 if self.banner_position != position {
609 self.banner_position = position;
610 if self.freeze.enabled {
611 self.freeze.request_capture();
612 }
613 }
614 }
615
616 pub fn is_state_overlay_visible(&self) -> bool {
618 matches!(self.freeze.overlay, Some(DebugOverlay::State(_)))
619 }
620
621 pub fn freeze(&self) -> &DebugFreeze<A> {
623 &self.freeze
624 }
625
626 pub fn freeze_mut(&mut self) -> &mut DebugFreeze<A> {
628 &mut self.freeze
629 }
630
631 pub fn log_action<T: tui_dispatch_core::ActionParams>(&mut self, action: &T) {
635 if self.active {
636 self.action_log.log(action);
637 }
638 }
639
640 pub fn action_log(&self) -> &ActionLog {
642 &self.action_log
643 }
644
645 pub fn render<F>(&mut self, frame: &mut Frame, render_fn: F)
650 where
651 F: FnOnce(&mut Frame, Rect),
652 {
653 self.render_with_state(frame, |frame, area, _wants_state| {
654 render_fn(frame, area);
655 None
656 });
657 }
658
659 pub fn render_with_state<F>(&mut self, frame: &mut Frame, render_fn: F)
665 where
666 F: FnOnce(&mut Frame, Rect, bool) -> Option<DebugTableOverlay>,
667 {
668 let screen = frame.area();
669
670 if !self.active || !self.freeze.enabled {
672 let _ = render_fn(frame, screen, false);
673 return;
674 }
675
676 let (app_area, banner_area) = self.split_for_banner(screen);
678
679 if self.freeze.pending_capture || self.freeze.snapshot.is_none() {
680 let state_snapshot = render_fn(frame, app_area, true);
682 self.state_snapshot = state_snapshot;
683 if let Some(ref table) = self.state_snapshot {
684 if self.is_state_overlay_visible() {
685 self.set_state_overlay(table.clone());
686 }
687 }
688 let buffer_clone = frame.buffer_mut().clone();
689 self.freeze.capture(&buffer_clone);
690 } else if let Some(ref snapshot) = self.freeze.snapshot {
691 paint_snapshot(frame, snapshot);
693 }
694
695 self.render_debug_overlay(frame, app_area, banner_area);
697 }
698
699 pub fn render_state<S: DebugState, F>(&mut self, frame: &mut Frame, state: &S, render_fn: F)
703 where
704 F: FnOnce(&mut Frame, Rect),
705 {
706 self.render_with_state(frame, |frame, area, wants_state| {
707 render_fn(frame, area);
708 if wants_state {
709 Some(state.build_debug_table("Application State"))
710 } else {
711 None
712 }
713 });
714 }
715
716 pub fn split_area(&self, area: Rect) -> (Rect, Rect) {
718 if !self.freeze.enabled {
719 return (area, Rect::ZERO);
720 }
721 self.split_for_banner(area)
722 }
723
724 pub fn intercepts(&mut self, event: &tui_dispatch_core::EventKind) -> bool {
738 self.intercepts_with_effects(event).is_some()
739 }
740
741 pub fn handle_event(&mut self, event: &tui_dispatch_core::EventKind) -> DebugOutcome<A> {
743 self.handle_event_internal::<()>(event, None)
744 }
745
746 pub fn handle_event_with_state<S: DebugState + 'static>(
748 &mut self,
749 event: &tui_dispatch_core::EventKind,
750 state: &S,
751 ) -> DebugOutcome<A> {
752 self.handle_event_internal(event, Some(state))
753 }
754
755 pub fn intercepts_with_effects(
759 &mut self,
760 event: &tui_dispatch_core::EventKind,
761 ) -> Option<Vec<DebugSideEffect<A>>> {
762 self.intercepts_with_effects_internal::<()>(event, None)
763 }
764
765 pub fn intercepts_with_effects_and_state<S: DebugState + 'static>(
769 &mut self,
770 event: &tui_dispatch_core::EventKind,
771 state: &S,
772 ) -> Option<Vec<DebugSideEffect<A>>> {
773 self.intercepts_with_effects_internal(event, Some(state))
774 }
775
776 pub fn intercepts_with_state<S: DebugState + 'static>(
778 &mut self,
779 event: &tui_dispatch_core::EventKind,
780 state: &S,
781 ) -> bool {
782 self.intercepts_with_effects_internal(event, Some(state))
783 .is_some()
784 }
785
786 fn handle_event_internal<S: DebugState + 'static>(
787 &mut self,
788 event: &tui_dispatch_core::EventKind,
789 state: Option<&S>,
790 ) -> DebugOutcome<A> {
791 let effects = self.intercepts_with_effects_internal(event, state);
792 let Some(effects) = effects else {
793 return DebugOutcome::ignored();
794 };
795
796 let mut queued_actions = Vec::new();
797 for effect in effects {
798 if let DebugSideEffect::ProcessQueuedActions(actions) = effect {
799 queued_actions.extend(actions);
800 }
801 }
802
803 DebugOutcome::consumed(queued_actions)
804 }
805
806 fn intercepts_with_effects_internal<S: DebugState + 'static>(
807 &mut self,
808 event: &tui_dispatch_core::EventKind,
809 state: Option<&S>,
810 ) -> Option<Vec<DebugSideEffect<A>>> {
811 if !self.active {
812 return None;
813 }
814
815 use tui_dispatch_core::EventKind;
816
817 match event {
818 EventKind::Key(key) => self.handle_key_event(*key, state),
819 EventKind::Mouse(mouse) => {
820 if !self.freeze.enabled {
821 return None;
822 }
823
824 if !self.freeze.mouse_capture_enabled {
827 return None;
828 }
829
830 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
832 let effect = self.handle_action(DebugAction::InspectCell {
833 column: mouse.column,
834 row: mouse.row,
835 });
836 return Some(effect.into_iter().collect());
837 }
838
839 Some(vec![])
841 }
842 EventKind::Scroll {
843 delta,
844 column,
845 row,
846 modifiers,
847 } => {
848 if !self.freeze.enabled {
849 return None;
850 }
851
852 match self.freeze.overlay.as_ref() {
853 Some(DebugOverlay::ActionLog(_)) => {
854 let action = if *delta > 0 {
855 DebugAction::ActionLogScrollUp
856 } else {
857 DebugAction::ActionLogScrollDown
858 };
859 self.handle_action(action);
860 }
861 Some(DebugOverlay::State(_)) => {
862 self.sync_state_tree_state();
863 let nodes = &self.state_tree_nodes;
864 let selected_id = self.state_tree_selected.as_ref();
865 let expanded_ids = &self.state_tree_expanded;
866 let style = self.state_tree_style();
867 let props =
868 Self::build_state_tree_props(nodes, selected_id, expanded_ids, style);
869 let actions: Vec<_> = self
870 .state_tree_view
871 .handle_event(
872 &EventKind::Scroll {
873 delta: *delta,
874 column: *column,
875 row: *row,
876 modifiers: *modifiers,
877 },
878 props,
879 )
880 .into_iter()
881 .collect();
882 if !actions.is_empty() {
883 self.apply_state_tree_actions(actions);
884 }
885 }
886 Some(DebugOverlay::Inspect(table)) => {
887 if *delta > 0 {
888 self.scroll_table_up();
889 } else {
890 self.scroll_table_down(table.rows.len());
891 }
892 }
893 Some(DebugOverlay::ActionDetail(detail)) => {
894 let params_lines = self.detail_params_lines(detail);
895 if *delta > 0 {
896 self.scroll_detail_up();
897 } else {
898 self.scroll_detail_down(params_lines.len());
899 }
900 }
901 _ => {}
902 }
903
904 Some(vec![])
905 }
906 EventKind::Resize(_, _) | EventKind::Tick => None,
908 }
909 }
910
911 pub fn show_state_overlay<S: DebugState>(&mut self, state: &S) {
913 let table = state.build_debug_table("Application State");
914 self.set_state_overlay(table);
915 }
916
917 pub fn show_action_log(&mut self) {
919 let overlay = ActionLogOverlay::from_log(&self.action_log, "Action Log");
920 self.freeze.set_overlay(DebugOverlay::ActionLog(overlay));
921 }
922
923 pub fn queue_action(&mut self, action: A) {
925 self.freeze.queue(action);
926 }
927
928 pub fn take_queued_actions(&mut self) -> Vec<A> {
933 std::mem::take(&mut self.freeze.queued_actions)
934 }
935
936 fn set_state_overlay(&mut self, table: DebugTableOverlay) {
941 let is_new_overlay = !matches!(self.freeze.overlay, Some(DebugOverlay::State(_)));
942 if is_new_overlay {
943 self.table_scroll_offset = 0;
944 self.state_tree_view = TreeView::new();
945 self.state_tree_selected = None;
946 self.state_tree_expanded.clear();
947 }
948
949 self.state_tree_nodes = self.build_state_tree_nodes(&table);
950 if is_new_overlay {
951 self.state_tree_expanded = self
952 .state_tree_nodes
953 .iter()
954 .map(|node| node.id.clone())
955 .collect();
956 }
957 self.sync_state_tree_state();
958 self.state_snapshot = Some(table.clone());
959 self.freeze.set_overlay(DebugOverlay::State(table));
960 }
961
962 fn build_state_tree_nodes(&self, table: &DebugTableOverlay) -> Vec<TreeNode<String, String>> {
963 let mut sections: Vec<TreeNode<String, String>> = Vec::new();
964 let mut current: Option<TreeNode<String, String>> = None;
965 let mut section_index = 0usize;
966 let mut entry_index = 0usize;
967 let mut current_section_index = 0usize;
968
969 for row in &table.rows {
970 match row {
971 DebugTableRow::Section(title) => {
972 if let Some(section) = current.take() {
973 sections.push(section);
974 }
975 current_section_index = section_index;
976 entry_index = 0;
977 let id = format!("section:{section_index}:{title}");
978 section_index = section_index.saturating_add(1);
979 current = Some(TreeNode::with_children(id, title.clone(), Vec::new()));
980 }
981 DebugTableRow::Entry { key, value } => {
982 if current.is_none() {
983 current_section_index = section_index;
984 entry_index = 0;
985 let id = format!("section:{section_index}:State");
986 section_index = section_index.saturating_add(1);
987 current =
988 Some(TreeNode::with_children(id, "State".to_string(), Vec::new()));
989 }
990 if let Some(section) = current.as_mut() {
991 let entry_id = format!("entry:{current_section_index}:{entry_index}:{key}");
992 entry_index = entry_index.saturating_add(1);
993 section
994 .children
995 .push(TreeNode::new(entry_id, format!("{key}: {value}")));
996 }
997 }
998 }
999 }
1000
1001 if let Some(section) = current.take() {
1002 sections.push(section);
1003 }
1004
1005 sections
1006 }
1007
1008 fn sync_state_tree_state(&mut self) {
1009 let nodes = &self.state_tree_nodes;
1010 self.state_tree_expanded
1011 .retain(|id| Self::tree_contains_id(nodes, id));
1012
1013 let selected_valid = self
1014 .state_tree_selected
1015 .as_deref()
1016 .map(|id| self.state_tree_contains_id(id))
1017 .unwrap_or(false);
1018
1019 if !selected_valid {
1020 self.state_tree_selected = self.state_tree_nodes.first().map(|node| node.id.clone());
1021 }
1022 }
1023
1024 fn state_tree_contains_id(&self, id: &str) -> bool {
1025 Self::tree_contains_id(&self.state_tree_nodes, id)
1026 }
1027
1028 fn tree_contains_id(nodes: &[TreeNode<String, String>], id: &str) -> bool {
1029 nodes
1030 .iter()
1031 .any(|node| node.id == id || Self::tree_contains_id(&node.children, id))
1032 }
1033
1034 fn state_tree_style(&self) -> TreeViewStyle {
1035 TreeViewStyle {
1036 base: BaseStyle {
1037 border: None,
1038 padding: Padding::default(),
1039 bg: Some(DebugStyle::overlay_bg()),
1040 fg: Some(DebugStyle::text_primary()),
1041 },
1042 selection: SelectionStyle::disabled(),
1043 scrollbar: self.component_scrollbar_style(),
1044 branches: TreeBranchStyle {
1045 mode: TreeBranchMode::Branch,
1046 connector_style: Style::default().fg(DebugStyle::text_secondary()),
1047 ..Default::default()
1048 },
1049 }
1050 }
1051
1052 fn build_state_tree_props<'a>(
1053 nodes: &'a [TreeNode<String, String>],
1054 selected_id: Option<&'a String>,
1055 expanded_ids: &'a HashSet<String>,
1056 style: TreeViewStyle,
1057 ) -> TreeViewProps<'a, String, String, StateTreeAction> {
1058 TreeViewProps {
1059 nodes,
1060 selected_id,
1061 expanded_ids,
1062 is_focused: true,
1063 style,
1064 behavior: TreeViewBehavior::default(),
1065 measure_node: None, column_padding: 0,
1067 on_select: |id| state_tree_select(id),
1068 on_toggle: |id, expanded| state_tree_toggle(id, expanded),
1069 render_node: &render_state_tree_node,
1070 }
1071 }
1072
1073 fn apply_state_tree_actions(&mut self, actions: impl IntoIterator<Item = StateTreeAction>) {
1074 for action in actions {
1075 match action {
1076 StateTreeAction::Select(id) => {
1077 self.state_tree_selected = Some(id);
1078 }
1079 StateTreeAction::Toggle(id, expand) => {
1080 if expand {
1081 self.state_tree_expanded.insert(id);
1082 } else {
1083 self.state_tree_expanded.remove(&id);
1084 }
1085 }
1086 }
1087 }
1088 }
1089
1090 fn save_state_snapshot<S: DebugState + 'static>(&mut self, state: Option<&S>) {
1091 let Some(state) = state else {
1092 self.freeze
1093 .set_message("State unavailable: call render_state() first");
1094 return;
1095 };
1096
1097 let path = self.state_snapshot_path();
1098
1099 let result = if let Some(snapshotter) = self.state_snapshotter.as_ref() {
1100 snapshotter(state as &dyn Any, &path)
1101 } else {
1102 let snapshot = DebugStateSnapshot::from_state(state, "Application State");
1103 crate::save_json(&path, &snapshot)
1104 };
1105
1106 match result {
1107 Ok(()) => self
1108 .freeze
1109 .set_message(format!("Saved state: {}", path.display())),
1110 Err(err) => self
1111 .freeze
1112 .set_message(format!("State save failed: {err:?}")),
1113 }
1114 }
1115
1116 fn state_snapshot_path(&self) -> PathBuf {
1117 let timestamp = SystemTime::now()
1118 .duration_since(UNIX_EPOCH)
1119 .unwrap_or_default()
1120 .as_millis();
1121 PathBuf::from(format!("debug-state-{timestamp}.json"))
1122 }
1123
1124 #[allow(dead_code)]
1125 fn update_table_scroll(&mut self, table: &DebugTableOverlay, visible_rows: usize) {
1126 self.table_page_size = visible_rows.max(1);
1127 let max_offset = table.rows.len().saturating_sub(visible_rows);
1128 self.table_scroll_offset = self.table_scroll_offset.min(max_offset);
1129 }
1130
1131 fn overlay_modal_area(&self, app_area: Rect) -> Rect {
1132 let modal_width = (app_area.width * 80 / 100)
1133 .clamp(40, 160)
1134 .min(app_area.width);
1135 let modal_height = (app_area.height * 80 / 100)
1136 .clamp(12, 50)
1137 .min(app_area.height);
1138 centered_rect(modal_width, modal_height, app_area)
1139 }
1140
1141 fn overlay_modal_style(&self) -> ModalStyle {
1142 ModalStyle {
1143 dim_factor: 0.0,
1144 base: BaseStyle {
1145 border: None,
1146 padding: Padding::default(), bg: Some(DebugStyle::overlay_bg()),
1148 fg: None,
1149 },
1150 }
1151 }
1152
1153 fn render_overlay_container<F>(
1154 &self,
1155 frame: &mut Frame,
1156 app_area: Rect,
1157 title: &str,
1158 hints: Option<ModalHints>,
1159 footer_center_items: Option<Vec<StatusBarItem<'static>>>,
1160 mut render_body: F,
1161 ) where
1162 F: FnMut(&mut Frame, Rect),
1163 {
1164 let modal_area = self.overlay_modal_area(app_area);
1165 let mut modal = Modal::new();
1166 let mut render_content = |frame: &mut Frame, content_area: Rect| {
1167 if content_area.height == 0 || content_area.width == 0 {
1168 return;
1169 }
1170
1171 let content_area = self.render_overlay_title(frame, content_area, title);
1173 if content_area.height == 0 || content_area.width == 0 {
1174 return;
1175 }
1176
1177 let content_area = if let Some(hints) = hints {
1179 self.render_overlay_footer(
1180 frame,
1181 content_area,
1182 hints,
1183 footer_center_items.as_deref(),
1184 )
1185 } else {
1186 content_area
1187 };
1188 if content_area.height == 0 || content_area.width == 0 {
1189 return;
1190 }
1191
1192 let body_area = Rect {
1194 x: content_area.x.saturating_add(1),
1195 y: content_area.y,
1196 width: content_area.width.saturating_sub(2),
1197 height: content_area.height,
1198 };
1199 if body_area.width == 0 {
1200 return;
1201 }
1202
1203 render_body(frame, body_area);
1204 };
1205
1206 modal.render(
1207 frame,
1208 app_area,
1209 ModalProps {
1210 is_open: true,
1211 is_focused: false,
1212 area: modal_area,
1213 style: self.overlay_modal_style(),
1214 behavior: ModalBehavior::default(),
1215 on_close: || (),
1216 render_content: &mut render_content,
1217 },
1218 );
1219 }
1220
1221 fn render_overlay_title(&self, frame: &mut Frame, area: Rect, title: &str) -> Rect {
1222 if area.height == 0 || area.width == 0 {
1223 return area;
1224 }
1225
1226 use ratatui::text::Span;
1227
1228 let title_style = Style::default()
1229 .fg(DebugStyle::accent())
1230 .add_modifier(Modifier::BOLD);
1231 let title_items = [StatusBarItem::span(Span::styled(
1232 format!(" {title} "),
1233 title_style,
1234 ))];
1235 let title_bar_style = StatusBarStyle {
1236 base: BaseStyle {
1237 border: None,
1238 padding: Padding::default(),
1239 bg: None,
1240 fg: None,
1241 },
1242 text: Style::default().fg(DebugStyle::accent()),
1243 hint_key: title_style,
1244 hint_label: Style::default().fg(DebugStyle::accent()),
1245 separator: Style::default().fg(DebugStyle::accent()),
1246 };
1247 let title_area = Rect { height: 1, ..area };
1248
1249 let mut status_bar = StatusBar::new();
1250 <StatusBar as tui_dispatch_core::Component<()>>::render(
1251 &mut status_bar,
1252 frame,
1253 title_area,
1254 StatusBarProps {
1255 left: StatusBarSection::empty(),
1256 center: StatusBarSection::items(&title_items),
1257 right: StatusBarSection::empty(),
1258 style: title_bar_style,
1259 is_focused: false,
1260 },
1261 );
1262
1263 Rect {
1264 x: area.x,
1265 y: area.y.saturating_add(title_area.height),
1266 width: area.width,
1267 height: area.height.saturating_sub(title_area.height),
1268 }
1269 }
1270
1271 fn render_overlay_footer(
1274 &self,
1275 frame: &mut Frame,
1276 area: Rect,
1277 hints: ModalHints,
1278 center_items: Option<&[StatusBarItem<'static>]>,
1279 ) -> Rect {
1280 if area.height < 2 || area.width == 0 {
1281 return area;
1282 }
1283
1284 let footer_area = Rect {
1285 x: area.x,
1286 y: area.y.saturating_add(area.height.saturating_sub(1)),
1287 width: area.width,
1288 height: 1,
1289 };
1290
1291 let key_style = Style::default()
1293 .fg(DebugStyle::bg_deep())
1294 .bg(DebugStyle::text_secondary())
1295 .add_modifier(Modifier::BOLD);
1296 let label_style = Style::default().fg(DebugStyle::text_secondary());
1297
1298 let mut left_items: Vec<StatusBarItem<'static>> = Vec::new();
1300 for hint in hints.left {
1301 left_items.push(StatusBarItem::span(Span::styled(
1302 format!(" {} ", hint.key),
1303 key_style,
1304 )));
1305 left_items.push(StatusBarItem::span(Span::styled(
1306 format!(" {} ", hint.label),
1307 label_style,
1308 )));
1309 }
1310
1311 let mut right_items: Vec<StatusBarItem<'static>> = Vec::new();
1313 for hint in hints.right {
1314 right_items.push(StatusBarItem::span(Span::styled(
1315 format!(" {} ", hint.key),
1316 key_style,
1317 )));
1318 right_items.push(StatusBarItem::span(Span::styled(
1319 format!(" {} ", hint.label),
1320 label_style,
1321 )));
1322 }
1323
1324 let footer_style = StatusBarStyle {
1325 base: BaseStyle {
1326 border: None,
1327 padding: Padding::default(),
1328 bg: Some(DebugStyle::overlay_bg_dark()),
1329 fg: None,
1330 },
1331 text: label_style,
1332 hint_key: key_style,
1333 hint_label: label_style,
1334 separator: label_style,
1335 };
1336
1337 let left = StatusBarSection::items(&left_items).with_separator("");
1338 let center = if let Some(items) = center_items {
1339 StatusBarSection::items(items).with_separator("")
1340 } else {
1341 StatusBarSection::empty()
1342 };
1343 let right = if right_items.is_empty() {
1344 StatusBarSection::empty()
1345 } else {
1346 StatusBarSection::items(&right_items).with_separator("")
1347 };
1348
1349 let mut status_bar = StatusBar::new();
1350 <StatusBar as tui_dispatch_core::Component<()>>::render(
1351 &mut status_bar,
1352 frame,
1353 footer_area,
1354 StatusBarProps {
1355 left,
1356 center,
1357 right,
1358 style: footer_style,
1359 is_focused: false,
1360 },
1361 );
1362
1363 Rect {
1365 x: area.x,
1366 y: area.y,
1367 width: area.width,
1368 height: area.height.saturating_sub(1),
1369 }
1370 }
1371
1372 fn component_scrollbar_style(&self) -> ComponentScrollbarStyle {
1373 let style = &self.style.scrollbar;
1374 ComponentScrollbarStyle {
1375 thumb: style.thumb,
1376 track: style.track,
1377 begin: style.begin,
1378 end: style.end,
1379 thumb_symbol: style.thumb_symbol,
1380 track_symbol: style.track_symbol,
1381 begin_symbol: style.begin_symbol,
1382 end_symbol: style.end_symbol,
1383 }
1384 }
1385
1386 fn table_page_size_value(&self) -> usize {
1387 self.table_page_size.max(1)
1388 }
1389
1390 fn table_max_offset(&self, rows_len: usize) -> usize {
1391 rows_len.saturating_sub(self.table_page_size_value())
1392 }
1393
1394 fn scroll_table_up(&mut self) {
1395 self.table_scroll_offset = self.table_scroll_offset.saturating_sub(1);
1396 }
1397
1398 fn scroll_table_down(&mut self, rows_len: usize) {
1399 let max_offset = self.table_max_offset(rows_len);
1400 self.table_scroll_offset = (self.table_scroll_offset + 1).min(max_offset);
1401 }
1402
1403 fn scroll_table_to_top(&mut self) {
1404 self.table_scroll_offset = 0;
1405 }
1406
1407 fn scroll_table_to_bottom(&mut self, rows_len: usize) {
1408 self.table_scroll_offset = self.table_max_offset(rows_len);
1409 }
1410
1411 fn scroll_table_page_up(&mut self) {
1412 let page_size = self.table_page_size_value();
1413 self.table_scroll_offset = self.table_scroll_offset.saturating_sub(page_size);
1414 }
1415
1416 fn scroll_table_page_down(&mut self, rows_len: usize) {
1417 let page_size = self.table_page_size_value();
1418 let max_offset = self.table_max_offset(rows_len);
1419 self.table_scroll_offset = (self.table_scroll_offset + page_size).min(max_offset);
1420 }
1421
1422 fn detail_page_size_value(&self) -> usize {
1423 self.detail_page_size.max(1)
1424 }
1425
1426 fn detail_max_offset(&self, rows_len: usize) -> usize {
1427 rows_len.saturating_sub(self.detail_page_size_value())
1428 }
1429
1430 fn scroll_detail_up(&mut self) {
1431 self.detail_scroll_offset = self.detail_scroll_offset.saturating_sub(1);
1432 }
1433
1434 fn scroll_detail_down(&mut self, rows_len: usize) {
1435 let max_offset = self.detail_max_offset(rows_len);
1436 self.detail_scroll_offset = (self.detail_scroll_offset + 1).min(max_offset);
1437 }
1438
1439 fn scroll_detail_to_top(&mut self) {
1440 self.detail_scroll_offset = 0;
1441 }
1442
1443 fn scroll_detail_to_bottom(&mut self, rows_len: usize) {
1444 self.detail_scroll_offset = self.detail_max_offset(rows_len);
1445 }
1446
1447 fn scroll_detail_page_up(&mut self) {
1448 let page_size = self.detail_page_size_value();
1449 self.detail_scroll_offset = self.detail_scroll_offset.saturating_sub(page_size);
1450 }
1451
1452 fn scroll_detail_page_down(&mut self, rows_len: usize) {
1453 let page_size = self.detail_page_size_value();
1454 let max_offset = self.detail_max_offset(rows_len);
1455 self.detail_scroll_offset = (self.detail_scroll_offset + page_size).min(max_offset);
1456 }
1457
1458 fn handle_table_scroll_key(&mut self, key: KeyCode, rows_len: usize) -> bool {
1459 match key {
1460 KeyCode::Char('j') | KeyCode::Down => {
1461 self.scroll_table_down(rows_len);
1462 true
1463 }
1464 KeyCode::Char('k') | KeyCode::Up => {
1465 self.scroll_table_up();
1466 true
1467 }
1468 KeyCode::Char('g') => {
1469 self.scroll_table_to_top();
1470 true
1471 }
1472 KeyCode::Char('G') => {
1473 self.scroll_table_to_bottom(rows_len);
1474 true
1475 }
1476 KeyCode::PageDown => {
1477 self.scroll_table_page_down(rows_len);
1478 true
1479 }
1480 KeyCode::PageUp => {
1481 self.scroll_table_page_up();
1482 true
1483 }
1484 _ => false,
1485 }
1486 }
1487
1488 fn handle_detail_scroll_key(&mut self, key: KeyCode, rows_len: usize) -> bool {
1489 match key {
1490 KeyCode::Char('j') | KeyCode::Down => {
1491 self.scroll_detail_down(rows_len);
1492 true
1493 }
1494 KeyCode::Char('k') | KeyCode::Up => {
1495 self.scroll_detail_up();
1496 true
1497 }
1498 KeyCode::Char('g') => {
1499 self.scroll_detail_to_top();
1500 true
1501 }
1502 KeyCode::Char('G') => {
1503 self.scroll_detail_to_bottom(rows_len);
1504 true
1505 }
1506 KeyCode::PageDown => {
1507 self.scroll_detail_page_down(rows_len);
1508 true
1509 }
1510 KeyCode::PageUp => {
1511 self.scroll_detail_page_up();
1512 true
1513 }
1514 _ => false,
1515 }
1516 }
1517
1518 fn handle_key_event<S: DebugState + 'static>(
1519 &mut self,
1520 key: KeyEvent,
1521 state: Option<&S>,
1522 ) -> Option<Vec<DebugSideEffect<A>>> {
1523 if key.code == self.toggle_key && key.modifiers.is_empty() {
1525 let effect = self.toggle();
1526 return Some(effect.into_iter().collect());
1527 }
1528
1529 if !self.freeze.enabled {
1531 return None;
1532 }
1533
1534 if matches!(self.freeze.overlay, Some(DebugOverlay::ActionLog(_))) {
1539 let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay else {
1540 return Some(vec![]);
1541 };
1542
1543 if log.search_input_active {
1544 let is_text_input_char = !key.modifiers.contains(KeyModifiers::CONTROL)
1545 && !key.modifiers.contains(KeyModifiers::ALT);
1546 match key.code {
1547 KeyCode::Esc | KeyCode::Enter => {
1548 log.search_input_active = false;
1549 return Some(vec![]);
1550 }
1551 KeyCode::Backspace => {
1552 if !log.pop_search_char() {
1553 log.search_input_active = false;
1554 }
1555 return Some(vec![]);
1556 }
1557 KeyCode::Char(c) if is_text_input_char => {
1558 log.push_search_char(c);
1559 return Some(vec![]);
1560 }
1561 _ => {
1562 return Some(vec![]);
1563 }
1564 }
1565 }
1566
1567 if key.code == KeyCode::Backspace {
1569 self.handle_action(DebugAction::CloseOverlay);
1570 return Some(vec![]);
1571 }
1572
1573 if key.code == KeyCode::Char('/') {
1574 log.search_input_active = true;
1575 return Some(vec![]);
1576 }
1577
1578 let prev_match = key.code == KeyCode::Char('N');
1580 if prev_match {
1581 log.search_prev();
1582 return Some(vec![]);
1583 }
1584
1585 if key.code == KeyCode::Char('n') {
1586 log.search_next();
1587 return Some(vec![]);
1588 }
1589
1590 let action = match key.code {
1591 KeyCode::Char('j') | KeyCode::Down => Some(DebugAction::ActionLogScrollDown),
1592 KeyCode::Char('k') | KeyCode::Up => Some(DebugAction::ActionLogScrollUp),
1593 KeyCode::Char('g') => Some(DebugAction::ActionLogScrollTop),
1594 KeyCode::Char('G') => Some(DebugAction::ActionLogScrollBottom),
1595 KeyCode::PageDown => Some(DebugAction::ActionLogPageDown),
1596 KeyCode::PageUp => Some(DebugAction::ActionLogPageUp),
1597 KeyCode::Enter => Some(DebugAction::ActionLogShowDetail),
1598 _ => None,
1599 };
1600 if let Some(action) = action {
1601 self.handle_action(action);
1602 return Some(vec![]);
1603 }
1604 }
1605
1606 if key.code == KeyCode::Esc {
1608 let effect = self.toggle();
1609 return Some(effect.into_iter().collect());
1610 }
1611
1612 match key.code {
1613 KeyCode::Char('b') | KeyCode::Char('B') => {
1614 self.banner_position = self.banner_position.toggle();
1615 self.freeze.request_capture();
1616 return Some(vec![]);
1617 }
1618 KeyCode::Char('s') | KeyCode::Char('S') => {
1619 if matches!(self.freeze.overlay, Some(DebugOverlay::State(_))) {
1620 self.freeze.clear_overlay();
1621 } else if let Some(state) = state {
1622 let table = state.build_debug_table("Application State");
1623 self.set_state_overlay(table);
1624 } else if let Some(ref table) = self.state_snapshot {
1625 self.set_state_overlay(table.clone());
1626 } else {
1627 let table = DebugTableBuilder::new()
1628 .section("State")
1629 .entry(
1630 "hint",
1631 "Press 's' after providing state via render_with_state() or show_state_overlay()",
1632 )
1633 .finish("Application State");
1634 self.freeze.set_overlay(DebugOverlay::State(table));
1635 }
1636 return Some(vec![]);
1637 }
1638 KeyCode::Char('w') | KeyCode::Char('W') => {
1639 self.save_state_snapshot(state);
1640 return Some(vec![]);
1641 }
1642 _ => {}
1643 }
1644
1645 let action = match key.code {
1649 KeyCode::Char('a') | KeyCode::Char('A') => Some(DebugAction::ToggleActionLog),
1650 KeyCode::Char('y') | KeyCode::Char('Y') => Some(DebugAction::CopyFrame),
1651 KeyCode::Char('i') | KeyCode::Char('I') => Some(DebugAction::ToggleMouseCapture),
1652 KeyCode::Char('q') | KeyCode::Char('Q') => Some(DebugAction::CloseOverlay),
1653 _ => None,
1654 };
1655
1656 if let Some(action) = action {
1657 let effect = self.handle_action(action);
1658 return Some(effect.into_iter().collect());
1659 }
1660
1661 match &self.freeze.overlay {
1663 Some(DebugOverlay::State(_)) => {
1664 if key.code == KeyCode::Backspace {
1666 self.handle_action(DebugAction::CloseOverlay);
1667 return Some(vec![]);
1668 }
1669 self.sync_state_tree_state();
1670 let nodes = &self.state_tree_nodes;
1671 let selected_id = self.state_tree_selected.as_ref();
1672 let expanded_ids = &self.state_tree_expanded;
1673 let style = self.state_tree_style();
1674 let props = Self::build_state_tree_props(nodes, selected_id, expanded_ids, style);
1675 let actions: Vec<_> = self
1676 .state_tree_view
1677 .handle_event(&EventKind::Key(key), props)
1678 .into_iter()
1679 .collect();
1680 if !actions.is_empty() {
1681 self.apply_state_tree_actions(actions);
1682 return Some(vec![]);
1683 }
1684 }
1685 Some(DebugOverlay::Inspect(table)) => {
1686 if key.code == KeyCode::Backspace {
1688 self.handle_action(DebugAction::CloseOverlay);
1689 return Some(vec![]);
1690 }
1691 if self.handle_table_scroll_key(key.code, table.rows.len()) {
1692 return Some(vec![]);
1693 }
1694 }
1695 Some(DebugOverlay::ActionDetail(detail)) => {
1696 if matches!(key.code, KeyCode::Esc | KeyCode::Backspace | KeyCode::Enter) {
1698 self.handle_action(DebugAction::ActionLogBackToList);
1699 return Some(vec![]);
1700 }
1701
1702 let params_lines = self.detail_params_lines(detail);
1703 if self.handle_detail_scroll_key(key.code, params_lines.len()) {
1704 return Some(vec![]);
1705 }
1706 }
1707 _ => {}
1708 }
1709
1710 Some(vec![])
1712 }
1713
1714 fn toggle(&mut self) -> Option<DebugSideEffect<A>> {
1715 if self.freeze.enabled {
1716 #[cfg(feature = "subscriptions")]
1718 if let Some(ref handle) = self.sub_handle {
1719 handle.resume();
1720 }
1721
1722 #[cfg(feature = "tasks")]
1723 let task_queued = if let Some(ref handle) = self.task_handle {
1724 handle.resume()
1725 } else {
1726 vec![]
1727 };
1728 #[cfg(not(feature = "tasks"))]
1729 let task_queued: Vec<A> = vec![];
1730
1731 let queued = self.freeze.take_queued();
1732 self.freeze.disable();
1733 self.state_snapshot = None;
1734 self.state_tree_nodes.clear();
1735 self.state_tree_selected = None;
1736 self.state_tree_expanded.clear();
1737 self.state_tree_view = TreeView::new();
1738 self.table_scroll_offset = 0;
1739 self.table_page_size = 1;
1740 self.detail_scroll_offset = 0;
1741 self.detail_page_size = 1;
1742
1743 let mut all_queued = queued;
1745 all_queued.extend(task_queued);
1746
1747 if all_queued.is_empty() {
1748 None
1749 } else {
1750 Some(DebugSideEffect::ProcessQueuedActions(all_queued))
1751 }
1752 } else {
1753 #[cfg(feature = "tasks")]
1755 if let Some(ref handle) = self.task_handle {
1756 handle.pause();
1757 }
1758 #[cfg(feature = "subscriptions")]
1759 if let Some(ref handle) = self.sub_handle {
1760 handle.pause();
1761 }
1762 self.freeze.enable();
1763 self.state_snapshot = None;
1764 self.state_tree_nodes.clear();
1765 self.state_tree_selected = None;
1766 self.state_tree_expanded.clear();
1767 self.state_tree_view = TreeView::new();
1768 self.table_scroll_offset = 0;
1769 self.table_page_size = 1;
1770 self.detail_scroll_offset = 0;
1771 self.detail_page_size = 1;
1772 None
1773 }
1774 }
1775
1776 fn handle_action(&mut self, action: DebugAction) -> Option<DebugSideEffect<A>> {
1777 match action {
1778 DebugAction::Toggle => self.toggle(),
1779 DebugAction::CopyFrame => {
1780 let text = &self.freeze.snapshot_text;
1781 let encoded = BASE64_STANDARD.encode(text);
1783 print!("\x1b]52;c;{}\x07", encoded);
1784 std::io::stdout().flush().ok();
1785 self.freeze.set_message("Copied to clipboard");
1786 None
1787 }
1788 DebugAction::ToggleState => {
1789 if matches!(self.freeze.overlay, Some(DebugOverlay::State(_))) {
1790 self.freeze.clear_overlay();
1791 } else if let Some(ref table) = self.state_snapshot {
1792 self.set_state_overlay(table.clone());
1793 } else {
1794 let table = DebugTableBuilder::new()
1796 .section("State")
1797 .entry(
1798 "hint",
1799 "Press 's' after providing state via render_with_state() or show_state_overlay()",
1800 )
1801 .finish("Application State");
1802 self.freeze.set_overlay(DebugOverlay::State(table));
1803 }
1804 None
1805 }
1806 DebugAction::ToggleActionLog => {
1807 if matches!(self.freeze.overlay, Some(DebugOverlay::ActionLog(_))) {
1808 self.freeze.clear_overlay();
1809 } else {
1810 self.show_action_log();
1811 }
1812 None
1813 }
1814 DebugAction::ActionLogScrollUp => {
1815 if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
1816 log.scroll_up();
1817 }
1818 None
1819 }
1820 DebugAction::ActionLogScrollDown => {
1821 if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
1822 log.scroll_down();
1823 }
1824 None
1825 }
1826 DebugAction::ActionLogScrollTop => {
1827 if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
1828 log.scroll_to_top();
1829 }
1830 None
1831 }
1832 DebugAction::ActionLogScrollBottom => {
1833 if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
1834 log.scroll_to_bottom();
1835 }
1836 None
1837 }
1838 DebugAction::ActionLogPageUp => {
1839 if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
1840 log.page_up(10);
1841 }
1842 None
1843 }
1844 DebugAction::ActionLogPageDown => {
1845 if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
1846 log.page_down(10);
1847 }
1848 None
1849 }
1850 DebugAction::ActionLogShowDetail => {
1851 if let Some(DebugOverlay::ActionLog(ref log)) = self.freeze.overlay {
1852 if let Some(detail) = log.selected_detail() {
1853 self.detail_scroll_offset = 0;
1854 self.detail_page_size = 1;
1855 self.freeze.set_overlay(DebugOverlay::ActionDetail(detail));
1856 }
1857 }
1858 None
1859 }
1860 DebugAction::ActionLogBackToList => {
1861 if matches!(self.freeze.overlay, Some(DebugOverlay::ActionDetail(_))) {
1863 self.show_action_log();
1864 }
1865 None
1866 }
1867 DebugAction::ToggleMouseCapture => {
1868 self.freeze.toggle_mouse_capture();
1869 None
1870 }
1871 DebugAction::InspectCell { column, row } => {
1872 if let Some(ref snapshot) = self.freeze.snapshot {
1873 let overlay = self.build_inspect_overlay(column, row, snapshot);
1874 self.table_scroll_offset = 0;
1875 self.freeze.set_overlay(DebugOverlay::Inspect(overlay));
1876 }
1877 self.freeze.mouse_capture_enabled = false;
1878 None
1879 }
1880 DebugAction::CloseOverlay => {
1881 self.freeze.clear_overlay();
1882 None
1883 }
1884 DebugAction::RequestCapture => {
1885 self.freeze.request_capture();
1886 None
1887 }
1888 }
1889 }
1890
1891 fn split_for_banner(&self, area: Rect) -> (Rect, Rect) {
1892 let banner_height = area.height.min(1);
1893 match self.banner_position {
1894 BannerPosition::Bottom => {
1895 let app_area = Rect {
1896 height: area.height.saturating_sub(banner_height),
1897 ..area
1898 };
1899 let banner_area = Rect {
1900 y: area.y.saturating_add(app_area.height),
1901 height: banner_height,
1902 ..area
1903 };
1904 (app_area, banner_area)
1905 }
1906 BannerPosition::Top => {
1907 let banner_area = Rect {
1908 y: area.y,
1909 height: banner_height,
1910 ..area
1911 };
1912 let app_area = Rect {
1913 y: area.y.saturating_add(banner_height),
1914 height: area.height.saturating_sub(banner_height),
1915 ..area
1916 };
1917 (app_area, banner_area)
1918 }
1919 }
1920 }
1921
1922 fn render_debug_overlay(&mut self, frame: &mut Frame, app_area: Rect, banner_area: Rect) {
1923 let overlay = self.freeze.overlay.clone();
1924
1925 if let Some(ref overlay) = overlay {
1927 dim_buffer(frame.buffer_mut(), self.style.dim_factor);
1928
1929 match overlay {
1930 DebugOverlay::Inspect(table) => {
1931 self.render_table_modal(frame, app_area, table);
1932 }
1933 DebugOverlay::State(table) => {
1934 self.render_state_tree_modal(frame, app_area, table);
1935 }
1936 DebugOverlay::ActionLog(log) => {
1937 self.render_action_log_modal(frame, app_area, log);
1938 }
1939 DebugOverlay::ActionDetail(detail) => {
1940 self.render_action_detail_modal(frame, app_area, detail);
1941 }
1942 }
1943 }
1944
1945 self.render_banner(frame, banner_area);
1947 }
1948
1949 fn render_banner(&self, frame: &mut Frame, banner_area: Rect) {
1950 if banner_area.height == 0 {
1951 return;
1952 }
1953
1954 use ratatui::text::Span;
1955
1956 let keys = &self.style.key_styles;
1957 let toggle_key_str = format_key(self.toggle_key);
1958 let label_style = self.style.label_style;
1959 let value_style = self.style.value_style;
1960
1961 let mut left_items: Vec<StatusBarItem<'static>> = Vec::new();
1962 left_items.push(StatusBarItem::span(Span::styled(
1963 " DEBUG ",
1964 self.style.title_style,
1965 )));
1966 left_items.push(StatusBarItem::text(" "));
1967
1968 let push_item =
1969 |items: &mut Vec<StatusBarItem<'static>>, key: &str, label: &str, key_style: Style| {
1970 items.push(StatusBarItem::span(Span::styled(
1971 format!(" {key} "),
1972 key_style,
1973 )));
1974 items.push(StatusBarItem::span(Span::styled(
1975 format!(" {label} "),
1976 label_style,
1977 )));
1978 };
1979
1980 push_item(&mut left_items, &toggle_key_str, "resume", keys.toggle);
1981 push_item(&mut left_items, "a", "actions", keys.actions);
1982 push_item(&mut left_items, "s", "state", keys.state);
1983 push_item(
1984 &mut left_items,
1985 "b",
1986 self.banner_position.label(),
1987 keys.actions,
1988 );
1989 push_item(&mut left_items, "y", "copy", keys.copy);
1990
1991 if self.freeze.mouse_capture_enabled {
1992 push_item(&mut left_items, "click", "inspect", keys.mouse);
1993 } else {
1994 push_item(&mut left_items, "i", "mouse", keys.mouse);
1995 }
1996
1997 let mut right_items: Vec<StatusBarItem<'static>> = Vec::new();
1998 if let Some(ref msg) = self.freeze.message {
1999 right_items.push(StatusBarItem::span(Span::styled(
2000 format!(" {msg} "),
2001 value_style,
2002 )));
2003 }
2004
2005 let style = StatusBarStyle {
2006 base: BaseStyle {
2007 border: None,
2008 padding: Padding::default(),
2009 bg: self.style.banner_bg.bg,
2010 fg: None,
2011 },
2012 text: label_style,
2013 hint_key: keys.toggle,
2014 hint_label: label_style,
2015 separator: label_style,
2016 };
2017
2018 let left = StatusBarSection::items(&left_items).with_separator("");
2019 let right = if right_items.is_empty() {
2020 StatusBarSection::empty()
2021 } else {
2022 StatusBarSection::items(&right_items).with_separator("")
2023 };
2024
2025 let mut status_bar = StatusBar::new();
2026 <StatusBar as tui_dispatch_core::Component<()>>::render(
2027 &mut status_bar,
2028 frame,
2029 banner_area,
2030 StatusBarProps {
2031 left,
2032 center: StatusBarSection::empty(),
2033 right,
2034 style,
2035 is_focused: false,
2036 },
2037 );
2038 }
2039
2040 fn render_state_tree_modal(
2041 &mut self,
2042 frame: &mut Frame,
2043 app_area: Rect,
2044 table: &DebugTableOverlay,
2045 ) {
2046 self.sync_state_tree_state();
2047
2048 let modal_area = self.overlay_modal_area(app_area);
2050 let modal_style = self.overlay_modal_style();
2051 let title = &table.title;
2052
2053 let mut modal = Modal::new();
2054 let mut render_content = |frame: &mut Frame, content_area: Rect| {
2055 if content_area.height == 0 || content_area.width == 0 {
2056 return;
2057 }
2058
2059 let content_area = self.render_overlay_title(frame, content_area, title);
2061 if content_area.height == 0 || content_area.width == 0 {
2062 return;
2063 }
2064
2065 let content_area =
2067 self.render_overlay_footer(frame, content_area, STATE_TREE_HINTS, None);
2068 if content_area.height == 0 || content_area.width == 0 {
2069 return;
2070 }
2071
2072 let body_area = Rect {
2074 x: content_area.x.saturating_add(1),
2075 y: content_area.y,
2076 width: content_area.width.saturating_sub(2),
2077 height: content_area.height,
2078 };
2079 if body_area.width == 0 {
2080 return;
2081 }
2082
2083 let nodes = &self.state_tree_nodes;
2084 let selected_id = self.state_tree_selected.as_ref();
2085 let expanded_ids = &self.state_tree_expanded;
2086 let style = self.state_tree_style();
2087 let props = Self::build_state_tree_props(nodes, selected_id, expanded_ids, style);
2088
2089 <TreeView<String> as tui_dispatch_core::Component<StateTreeAction>>::render(
2090 &mut self.state_tree_view,
2091 frame,
2092 body_area,
2093 props,
2094 );
2095 };
2096
2097 modal.render(
2098 frame,
2099 app_area,
2100 ModalProps {
2101 is_open: true,
2102 is_focused: false,
2103 area: modal_area,
2104 style: modal_style,
2105 behavior: ModalBehavior::default(),
2106 on_close: || (),
2107 render_content: &mut render_content,
2108 },
2109 );
2110 }
2111
2112 fn render_table_modal(&mut self, frame: &mut Frame, app_area: Rect, table: &DebugTableOverlay) {
2113 let scrollbar_style = self.component_scrollbar_style();
2114 let table_scroll_offset = self.table_scroll_offset;
2115
2116 self.render_overlay_container(
2117 frame,
2118 app_area,
2119 &table.title,
2120 Some(INSPECT_HINTS),
2121 None,
2122 |frame, content_area| {
2123 let mut table_area = content_area;
2124 if let Some(ref preview) = table.cell_preview {
2125 if table_area.height > 1 {
2126 let preview_area = Rect {
2127 height: 1,
2128 ..table_area
2129 };
2130 let preview_widget = CellPreviewWidget::new(preview)
2131 .label_style(Style::default().fg(DebugStyle::text_secondary()))
2132 .value_style(Style::default().fg(DebugStyle::text_primary()));
2133 frame.render_widget(preview_widget, preview_area);
2134
2135 table_area = Rect {
2136 y: table_area.y.saturating_add(1),
2137 height: table_area.height.saturating_sub(1),
2138 ..table_area
2139 };
2140 }
2141 }
2142
2143 if table_area.height == 0 || table_area.width == 0 {
2144 return;
2145 }
2146
2147 use ratatui::text::{Line, Span};
2148 use ratatui::widgets::Paragraph;
2149
2150 let header_area = Rect {
2151 height: 1,
2152 ..table_area
2153 };
2154 let rows_area = Rect {
2155 y: table_area.y.saturating_add(1),
2156 height: table_area.height.saturating_sub(1),
2157 ..table_area
2158 };
2159
2160 let style = DebugTableStyle::default();
2161 let show_scrollbar = rows_area.height > 0
2162 && table.rows.len() > rows_area.height as usize
2163 && table_area.width > 1;
2164 let text_width = if show_scrollbar {
2165 table_area.width.saturating_sub(1)
2166 } else {
2167 table_area.width
2168 } as usize;
2169
2170 if text_width == 0 {
2171 return;
2172 }
2173
2174 let max_key_len = table
2175 .rows
2176 .iter()
2177 .filter_map(|row| match row {
2178 super::table::DebugTableRow::Entry { key, .. } => Some(key.chars().count()),
2179 super::table::DebugTableRow::Section(_) => None,
2180 })
2181 .max()
2182 .unwrap_or(0);
2183 let max_label = text_width.saturating_sub(8).max(10);
2184 let label_width = (max_key_len + 2).clamp(12, 30).min(max_label);
2185 let value_width = text_width.saturating_sub(label_width + 2).max(1);
2186
2187 let header_line = Line::from(vec![
2188 Span::styled(pad_text("Field", label_width), style.header),
2189 Span::styled(" ", style.header),
2190 Span::styled(pad_text("Value", value_width), style.header),
2191 ]);
2192 frame.render_widget(Paragraph::new(header_line), header_area);
2193
2194 if rows_area.height == 0 {
2195 return;
2196 }
2197
2198 let syntax_style = DebugSyntaxStyle::with_base(style.value);
2199 let mut rows = Vec::new();
2200 let mut entry_index = 0usize;
2201 for row in table.rows.iter() {
2202 match row {
2203 super::table::DebugTableRow::Section(title) => {
2204 entry_index = 0;
2205 let mut text = format!(" {title} ");
2206 text = truncate_with_ellipsis(&text, text_width);
2207 text = pad_text(&text, text_width);
2208 rows.push(Line::from(vec![Span::styled(text, style.section)]));
2209 }
2210 super::table::DebugTableRow::Entry { key, value } => {
2211 let row_style = if entry_index % 2 == 0 {
2212 style.row_styles.0
2213 } else {
2214 style.row_styles.1
2215 };
2216 entry_index = entry_index.saturating_add(1);
2217 let key_text = pad_text(key, label_width);
2218 let mut value_text = truncate_with_ellipsis(value, value_width);
2219 value_text = pad_text(&value_text, value_width);
2220
2221 let mut value_spans: Vec<Span<'static>> =
2222 debug_spans(&value_text, &syntax_style)
2223 .into_iter()
2224 .map(|span| span.patch_style(row_style))
2225 .collect();
2226
2227 let mut spans = vec![
2228 Span::styled(key_text, style.key).patch_style(row_style),
2229 Span::styled(" ", row_style),
2230 ];
2231 spans.append(&mut value_spans);
2232
2233 rows.push(Line::from(spans));
2234 }
2235 }
2236 }
2237
2238 let scroll_style = ScrollViewStyle {
2240 base: BaseStyle {
2241 border: None,
2242 padding: Padding::default(),
2243 bg: None,
2244 fg: None,
2245 },
2246 scrollbar: scrollbar_style.clone(),
2247 };
2248 let scroller = LinesScroller::new(&rows);
2249 let mut scroll_view = ScrollView::new();
2250 <ScrollView as tui_dispatch_core::Component<()>>::render(
2251 &mut scroll_view,
2252 frame,
2253 rows_area,
2254 ScrollViewProps {
2255 content_height: scroller.content_height(),
2256 scroll_offset: table_scroll_offset,
2257 is_focused: true,
2258 style: scroll_style,
2259 behavior: ScrollViewBehavior::default(),
2260 on_scroll: |_| (),
2261 render_content: &mut scroller.renderer(),
2262 },
2263 );
2264 },
2265 );
2266 }
2267
2268 fn render_action_log_modal(&self, frame: &mut Frame, app_area: Rect, log: &ActionLogOverlay) {
2269 let entry_count = log.entries.len();
2270 let filter_active = log.has_search_query();
2271 let filtered_count = if filter_active {
2272 log.search_match_count()
2273 } else {
2274 entry_count
2275 };
2276 let title = if log.has_search_query() {
2277 format!("{} ({} / {} shown)", log.title, filtered_count, entry_count)
2278 } else if entry_count > 0 {
2279 format!("{} ({} entries)", log.title, entry_count)
2280 } else {
2281 format!("{} (empty)", log.title)
2282 };
2283
2284 let action_log_hints = if log.search_input_active {
2285 ACTION_LOG_SEARCH_INPUT_HINTS
2286 } else {
2287 ACTION_LOG_HINTS
2288 };
2289
2290 self.render_overlay_container(
2291 frame,
2292 app_area,
2293 &title,
2294 Some(action_log_hints),
2295 self.action_log_footer_center_items(log),
2296 |frame, log_area| {
2297 use ratatui::text::{Line, Span};
2298 use ratatui::widgets::Paragraph;
2299
2300 let style = ActionLogStyle::default();
2301 let spacing = 1usize;
2302 let seq_width = 5usize;
2303 let name_width = 20usize;
2304 let elapsed_width = 8usize;
2305
2306 let header_area = Rect {
2307 height: 1,
2308 ..log_area
2309 };
2310 let body_area = Rect {
2311 y: log_area.y.saturating_add(1),
2312 height: log_area.height.saturating_sub(1),
2313 ..log_area
2314 };
2315
2316 let selected_visible_idx = if filtered_count == 0 {
2317 0
2318 } else if filter_active {
2319 let selected_match_position = log
2320 .search_matches
2321 .iter()
2322 .position(|&idx| idx == log.selected);
2323 selected_match_position
2325 .unwrap_or(log.search_match_index.min(filtered_count.saturating_sub(1)))
2326 } else {
2327 log.selected.min(filtered_count.saturating_sub(1))
2328 };
2329
2330 let show_scrollbar = body_area.height > 0
2331 && filtered_count > body_area.height as usize
2332 && log_area.width > 1;
2333 let text_width = if show_scrollbar {
2334 log_area.width.saturating_sub(1)
2335 } else {
2336 log_area.width
2337 } as usize;
2338 let params_width = text_width
2339 .saturating_sub(seq_width + name_width + elapsed_width + spacing * 3)
2340 .max(1);
2341
2342 let header_line = Line::from(vec![
2343 Span::styled(pad_text("#", seq_width), style.header),
2344 Span::styled(" ", style.header),
2345 Span::styled(pad_text("Action", name_width), style.header),
2346 Span::styled(" ", style.header),
2347 Span::styled(pad_text("Params", params_width), style.header),
2348 Span::styled(" ", style.header),
2349 Span::styled(pad_text("Elapsed", elapsed_width), style.header),
2350 ]);
2351 frame.render_widget(Paragraph::new(header_line), header_area);
2352
2353 if body_area.height == 0 {
2354 return;
2355 }
2356
2357 let syntax_style = DebugSyntaxStyle::with_base(style.params);
2358 let rows: Vec<Line> = if filtered_count == 0 {
2359 vec![Line::from(vec![Span::styled(
2360 " (no matching actions) ",
2361 Style::default().fg(DebugStyle::text_secondary()),
2362 )])]
2363 } else {
2364 (0..filtered_count)
2365 .map(|visible_idx| {
2366 let entry_idx = if filter_active {
2367 log.search_matches[visible_idx]
2368 } else {
2369 visible_idx
2370 };
2371 let entry = &log.entries[entry_idx];
2372 let is_selected = visible_idx == selected_visible_idx;
2373 let row_style = if is_selected {
2374 style.selected
2375 } else if visible_idx % 2 == 0 {
2376 style.row_styles.0
2377 } else {
2378 style.row_styles.1
2379 };
2380
2381 let seq_text = pad_text(&entry.sequence.to_string(), seq_width);
2382 let name_text = pad_text(&entry.name, name_width);
2383 let elapsed_text = pad_text(&entry.elapsed, elapsed_width);
2384
2385 let params_compact = entry.params.replace('\n', " ");
2386 let params_compact = params_compact
2387 .split_whitespace()
2388 .collect::<Vec<_>>()
2389 .join(" ");
2390 let params_trimmed =
2391 truncate_with_ellipsis(¶ms_compact, params_width);
2392 let params_text = pad_text(¶ms_trimmed, params_width);
2393 let mut params_spans: Vec<Span<'static>> =
2394 debug_spans(¶ms_text, &syntax_style)
2395 .into_iter()
2396 .map(|span| span.patch_style(row_style))
2397 .collect();
2398
2399 let mut spans = vec![
2400 Span::styled(seq_text, style.sequence).patch_style(row_style),
2401 Span::styled(" ", row_style),
2402 Span::styled(name_text, style.name).patch_style(row_style),
2403 Span::styled(" ", row_style),
2404 ];
2405 spans.append(&mut params_spans);
2406 spans.push(Span::styled(" ", row_style));
2407 spans.push(
2408 Span::styled(elapsed_text, style.elapsed).patch_style(row_style),
2409 );
2410
2411 Line::from(spans)
2412 })
2413 .collect()
2414 };
2415
2416 let scroll_style = ScrollViewStyle {
2417 base: BaseStyle {
2418 border: None,
2419 padding: Padding::default(),
2420 bg: None,
2421 fg: None,
2422 },
2423 scrollbar: self.component_scrollbar_style(),
2424 };
2425 let content_height = rows.len();
2426 let visible_rows = body_area.height as usize;
2427 let scroll_offset = if visible_rows == 0 || selected_visible_idx < visible_rows {
2428 0
2429 } else {
2430 selected_visible_idx - visible_rows + 1
2431 };
2432 let scroller = LinesScroller::new(&rows);
2433 let mut scroll_view = ScrollView::new();
2434 <ScrollView as tui_dispatch_core::Component<()>>::render(
2435 &mut scroll_view,
2436 frame,
2437 body_area,
2438 ScrollViewProps {
2439 content_height,
2440 scroll_offset,
2441 is_focused: true,
2442 style: scroll_style,
2443 behavior: ScrollViewBehavior::default(),
2444 on_scroll: |_| (),
2445 render_content: &mut scroller.renderer(),
2446 },
2447 );
2448 },
2449 );
2450 }
2451
2452 fn action_log_footer_center_items(
2453 &self,
2454 log: &ActionLogOverlay,
2455 ) -> Option<Vec<StatusBarItem<'static>>> {
2456 use ratatui::text::Span;
2457
2458 let key_style = Style::default()
2459 .fg(DebugStyle::bg_deep())
2460 .bg(DebugStyle::text_secondary())
2461 .add_modifier(Modifier::BOLD);
2462 let label_style = Style::default().fg(DebugStyle::text_secondary());
2463 let value_style = Style::default().fg(DebugStyle::text_primary());
2464
2465 let filter_value = if log.search_input_active {
2466 format!("/{}_", log.search_query)
2467 } else if log.search_query.is_empty() {
2468 "off".to_string()
2469 } else {
2470 format!("/{}", log.search_query)
2471 };
2472
2473 let matches = if log.search_query.is_empty() {
2474 format!("{}", log.entries.len())
2475 } else {
2476 format!("{}", log.search_match_count())
2477 };
2478
2479 Some(vec![
2480 StatusBarItem::span(Span::styled(" filter ", key_style)),
2481 StatusBarItem::span(Span::styled(format!(" {} ", filter_value), value_style)),
2482 StatusBarItem::span(Span::styled(" matches ", key_style)),
2483 StatusBarItem::span(Span::styled(format!(" {} ", matches), label_style)),
2484 ])
2485 }
2486
2487 fn render_action_detail_modal(
2488 &mut self,
2489 frame: &mut Frame,
2490 app_area: Rect,
2491 detail: &super::table::ActionDetailOverlay,
2492 ) {
2493 let title = format!("Action #{} - {}", detail.sequence, detail.name);
2494 let param_lines = self.detail_params_lines(detail);
2495 let scrollbar_style = self.component_scrollbar_style();
2496 let detail_scroll_offset = self.detail_scroll_offset;
2497 let detail_page_size = self.detail_page_size;
2498
2499 self.render_overlay_container(
2500 frame,
2501 app_area,
2502 &title,
2503 Some(ACTION_DETAIL_HINTS),
2504 None,
2505 |frame, detail_area| {
2506 use ratatui::text::{Line, Span};
2507 use ratatui::widgets::Paragraph;
2508
2509 let label_style = Style::default().fg(DebugStyle::text_secondary());
2510 let value_style = Style::default().fg(DebugStyle::text_primary());
2511
2512 let header_lines = vec![
2513 Line::from(vec![
2514 Span::styled("Name: ", label_style),
2515 Span::styled(&detail.name, value_style),
2516 ]),
2517 Line::from(vec![
2518 Span::styled("Sequence: ", label_style),
2519 Span::styled(detail.sequence.to_string(), value_style),
2520 ]),
2521 Line::from(vec![
2522 Span::styled("Elapsed: ", label_style),
2523 Span::styled(&detail.elapsed, value_style),
2524 ]),
2525 Line::from(""),
2526 Line::from(Span::styled("Parameters:", label_style)),
2527 ];
2528
2529 let mut param_lines = param_lines.clone();
2530 if param_lines.is_empty() {
2531 param_lines.push(Line::from(Span::styled(" (none)", value_style)));
2532 }
2533 let param_lines_len = param_lines.len();
2534
2535 let footer_lines = vec![Line::from("")];
2536
2537 let header_height = header_lines.len() as u16;
2538 let footer_height = footer_lines.len() as u16;
2539
2540 let header_area_height = header_height.min(detail_area.height);
2541 let header_area = Rect {
2542 height: header_area_height,
2543 ..detail_area
2544 };
2545 if header_area.height > 0 {
2546 let paragraph = Paragraph::new(header_lines);
2547 frame.render_widget(paragraph, header_area);
2548 }
2549
2550 let footer_area_height =
2551 footer_height.min(detail_area.height.saturating_sub(header_area_height));
2552 let footer_area = Rect {
2553 x: detail_area.x,
2554 y: detail_area
2555 .y
2556 .saturating_add(detail_area.height.saturating_sub(footer_area_height)),
2557 width: detail_area.width,
2558 height: footer_area_height,
2559 };
2560 if footer_area.height > 0 {
2561 let paragraph = Paragraph::new(footer_lines);
2562 frame.render_widget(paragraph, footer_area);
2563 }
2564
2565 let params_area = Rect {
2566 x: detail_area.x,
2567 y: detail_area.y.saturating_add(header_area_height),
2568 width: detail_area.width,
2569 height: detail_area
2570 .height
2571 .saturating_sub(header_area_height + footer_area_height),
2572 };
2573
2574 let max_offset = param_lines_len.saturating_sub(detail_page_size.max(1));
2576 let current_scroll_offset = detail_scroll_offset.min(max_offset);
2577
2578 if params_area.height > 0 {
2579 let scroll_style = ScrollViewStyle {
2580 base: BaseStyle {
2581 border: None,
2582 padding: Padding::default(),
2583 bg: Some(DebugStyle::overlay_bg_dark()),
2584 fg: None,
2585 },
2586 scrollbar: scrollbar_style.clone(),
2587 };
2588
2589 let scroller = LinesScroller::new(¶m_lines);
2590 let mut scroll_view = ScrollView::new();
2591 <ScrollView as tui_dispatch_core::Component<()>>::render(
2592 &mut scroll_view,
2593 frame,
2594 params_area,
2595 ScrollViewProps {
2596 content_height: param_lines_len,
2597 scroll_offset: current_scroll_offset,
2598 is_focused: true,
2599 style: scroll_style,
2600 behavior: ScrollViewBehavior::default(),
2601 on_scroll: |_| (),
2602 render_content: &mut scroller.renderer(),
2603 },
2604 );
2605 }
2606 },
2607 );
2608 }
2609
2610 fn detail_params_lines(
2611 &self,
2612 detail: &super::table::ActionDetailOverlay,
2613 ) -> Vec<ratatui::text::Line<'static>> {
2614 use ratatui::text::{Line, Span};
2615
2616 if detail.params.is_empty() {
2617 return Vec::new();
2618 }
2619
2620 let value_style = Style::default().fg(DebugStyle::text_primary());
2621 let syntax_style = DebugSyntaxStyle::with_base(value_style);
2622
2623 detail
2624 .params
2625 .lines()
2626 .map(|line| {
2627 let mut spans = vec![Span::styled(" ", value_style)];
2628 spans.extend(debug_spans(line, &syntax_style));
2629 Line::from(spans)
2630 })
2631 .collect()
2632 }
2633
2634 fn build_inspect_overlay(&self, column: u16, row: u16, snapshot: &Buffer) -> DebugTableOverlay {
2635 let mut builder = DebugTableBuilder::new();
2636
2637 builder.push_section("Position");
2638 builder.push_entry("column", column.to_string());
2639 builder.push_entry("row", row.to_string());
2640
2641 if let Some(preview) = inspect_cell(snapshot, column, row) {
2642 builder.set_cell_preview(preview);
2643 }
2644
2645 builder.finish(format!("Inspect ({column}, {row})"))
2646 }
2647}
2648
2649#[derive(Debug, Serialize)]
2650struct DebugStateSnapshot {
2651 title: String,
2652 sections: Vec<DebugStateSnapshotSection>,
2653}
2654
2655#[derive(Debug, Serialize)]
2656struct DebugStateSnapshotSection {
2657 title: String,
2658 entries: Vec<DebugStateSnapshotEntry>,
2659}
2660
2661#[derive(Debug, Serialize)]
2662struct DebugStateSnapshotEntry {
2663 key: String,
2664 value: String,
2665}
2666
2667impl DebugStateSnapshot {
2668 fn from_state<S: DebugState>(state: &S, title: &str) -> Self {
2669 let sections = state
2670 .debug_sections()
2671 .into_iter()
2672 .map(|section| DebugStateSnapshotSection {
2673 title: section.title,
2674 entries: section
2675 .entries
2676 .into_iter()
2677 .map(|entry| DebugStateSnapshotEntry {
2678 key: entry.key,
2679 value: entry.value,
2680 })
2681 .collect(),
2682 })
2683 .collect();
2684
2685 Self {
2686 title: title.to_string(),
2687 sections,
2688 }
2689 }
2690}
2691
2692fn pad_text(value: &str, width: usize) -> String {
2693 if width == 0 {
2694 return String::new();
2695 }
2696 let mut text: String = value.chars().take(width).collect();
2697 let len = text.chars().count();
2698 if len < width {
2699 text.push_str(&" ".repeat(width - len));
2700 }
2701 text
2702}
2703
2704fn truncate_with_ellipsis(value: &str, width: usize) -> String {
2705 if width == 0 {
2706 return String::new();
2707 }
2708 let count = value.chars().count();
2709 if count <= width {
2710 return value.to_string();
2711 }
2712 if width <= 3 {
2713 return value.chars().take(width).collect();
2714 }
2715 let mut text: String = value.chars().take(width - 3).collect();
2716 text.push_str("...");
2717 text
2718}
2719
2720fn format_key(key: KeyCode) -> String {
2722 match key {
2723 KeyCode::F(n) => format!("F{}", n),
2724 KeyCode::Char(c) => c.to_string(),
2725 KeyCode::Esc => "Esc".to_string(),
2726 KeyCode::Enter => "Enter".to_string(),
2727 KeyCode::Tab => "Tab".to_string(),
2728 KeyCode::Backspace => "Bksp".to_string(),
2729 KeyCode::Delete => "Del".to_string(),
2730 KeyCode::Up => "↑".to_string(),
2731 KeyCode::Down => "↓".to_string(),
2732 KeyCode::Left => "←".to_string(),
2733 KeyCode::Right => "→".to_string(),
2734 _ => format!("{:?}", key),
2735 }
2736}
2737
2738#[cfg(test)]
2739mod tests {
2740 use super::*;
2741
2742 #[derive(Debug, Clone)]
2743 enum TestAction {
2744 Foo,
2745 Bar,
2746 }
2747
2748 impl tui_dispatch_core::Action for TestAction {
2749 fn name(&self) -> &'static str {
2750 match self {
2751 TestAction::Foo => "Foo",
2752 TestAction::Bar => "Bar",
2753 }
2754 }
2755 }
2756
2757 impl tui_dispatch_core::ActionParams for TestAction {
2758 fn params(&self) -> String {
2759 String::new()
2760 }
2761 }
2762
2763 #[test]
2764 fn test_debug_layer_creation() {
2765 let layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2766 assert!(!layer.is_enabled());
2767 assert!(layer.freeze().snapshot.is_none());
2768 }
2769
2770 #[test]
2771 fn test_toggle() {
2772 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2773
2774 let effect = layer.toggle();
2776 assert!(effect.is_none());
2777 assert!(layer.is_enabled());
2778
2779 let effect = layer.toggle();
2781 assert!(effect.is_none()); assert!(!layer.is_enabled());
2783 }
2784
2785 #[test]
2786 fn test_set_enabled() {
2787 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2788
2789 layer.set_enabled(true);
2790 assert!(layer.is_enabled());
2791
2792 layer.set_enabled(false);
2793 assert!(!layer.is_enabled());
2794 }
2795
2796 #[test]
2797 fn test_simple_constructor() {
2798 let layer: DebugLayer<TestAction> = DebugLayer::simple();
2799 assert!(!layer.is_enabled());
2800 }
2801
2802 #[test]
2803 fn test_queued_actions_returned_on_disable() {
2804 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2805
2806 layer.toggle(); layer.queue_action(TestAction::Foo);
2808 layer.queue_action(TestAction::Bar);
2809
2810 let effect = layer.toggle(); match effect {
2813 Some(DebugSideEffect::ProcessQueuedActions(actions)) => {
2814 assert_eq!(actions.len(), 2);
2815 }
2816 _ => panic!("Expected ProcessQueuedActions"),
2817 }
2818 }
2819
2820 #[test]
2821 fn test_split_area() {
2822 let layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2823
2824 let area = Rect::new(0, 0, 80, 24);
2826 let (app, banner) = layer.split_area(area);
2827 assert_eq!(app, area);
2828 assert_eq!(banner, Rect::ZERO);
2829 }
2830
2831 #[test]
2832 fn test_split_area_enabled() {
2833 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2834 layer.toggle();
2835
2836 let area = Rect::new(0, 0, 80, 24);
2837 let (app, banner) = layer.split_area(area);
2838
2839 assert_eq!(app.height, 23);
2840 assert_eq!(banner.height, 1);
2841 assert_eq!(banner.y, 23);
2842 }
2843
2844 #[test]
2845 fn test_split_area_enabled_top() {
2846 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2847 layer.toggle();
2848 layer.set_banner_position(BannerPosition::Top);
2849
2850 let area = Rect::new(0, 0, 80, 24);
2851 let (app, banner) = layer.split_area(area);
2852
2853 assert_eq!(banner.y, 0);
2854 assert_eq!(banner.height, 1);
2855 assert_eq!(app.y, 1);
2856 assert_eq!(app.height, 23);
2857 }
2858
2859 #[test]
2860 fn test_inactive_layer() {
2861 let layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12)).active(false);
2862
2863 assert!(!layer.is_active());
2864 assert!(!layer.is_enabled());
2865 }
2866
2867 #[test]
2868 fn test_action_log() {
2869 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2870
2871 layer.log_action(&TestAction::Foo);
2872 layer.log_action(&TestAction::Bar);
2873
2874 assert_eq!(layer.action_log().entries().count(), 2);
2875 }
2876
2877 #[test]
2878 fn test_action_log_filter_configuration() {
2879 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12))
2880 .with_action_log_filter(ActionLoggerConfig::new(Some("name:Foo"), None));
2881
2882 layer.log_action(&TestAction::Foo);
2883 layer.log_action(&TestAction::Bar);
2884
2885 let names: Vec<_> = layer
2886 .action_log()
2887 .entries()
2888 .map(|entry| entry.name)
2889 .collect();
2890 assert_eq!(names, vec!["Foo"]);
2891 }
2892
2893 #[test]
2894 fn test_action_log_search_input_does_not_trigger_global_shortcuts() {
2895 use crossterm::event::{KeyEventKind, KeyEventState};
2896 use tui_dispatch_core::EventKind;
2897
2898 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
2899 layer.toggle();
2900 layer.show_action_log();
2901
2902 if let Some(DebugOverlay::ActionLog(log)) = layer.freeze.overlay.as_mut() {
2903 log.search_input_active = true;
2904 } else {
2905 panic!("expected action log overlay");
2906 }
2907
2908 let keys = [
2909 KeyEvent {
2910 code: KeyCode::Char('A'),
2911 modifiers: KeyModifiers::SHIFT,
2912 kind: KeyEventKind::Press,
2913 state: KeyEventState::NONE,
2914 },
2915 KeyEvent {
2916 code: KeyCode::Char('s'),
2917 modifiers: KeyModifiers::NONE,
2918 kind: KeyEventKind::Press,
2919 state: KeyEventState::NONE,
2920 },
2921 KeyEvent {
2922 code: KeyCode::Char('b'),
2923 modifiers: KeyModifiers::NONE,
2924 kind: KeyEventKind::Press,
2925 state: KeyEventState::NONE,
2926 },
2927 KeyEvent {
2928 code: KeyCode::Char('i'),
2929 modifiers: KeyModifiers::NONE,
2930 kind: KeyEventKind::Press,
2931 state: KeyEventState::NONE,
2932 },
2933 ];
2934 for key in keys {
2935 let outcome = layer.handle_event(&EventKind::Key(key));
2936 assert!(outcome.consumed);
2937 }
2938
2939 match layer.freeze.overlay.as_ref() {
2940 Some(DebugOverlay::ActionLog(log)) => {
2941 assert!(log.search_input_active);
2942 assert_eq!(log.search_query, "Asbi");
2943 }
2944 _ => panic!("action log overlay should remain open"),
2945 }
2946 }
2947}