1use std::any::Any;
7use std::collections::HashSet;
8use std::io::{self, Write};
9use std::path::{Path, PathBuf};
10use std::rc::Rc;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use base64::prelude::*;
14use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind};
15use ratatui::buffer::Buffer;
16use ratatui::layout::Rect;
17use ratatui::style::{Modifier, Style, Stylize};
18use ratatui::text::{Line, Span};
19use ratatui::Frame;
20use serde::Serialize;
21
22use super::action_logger::{ActionLog, ActionLogConfig, ActionLoggerConfig};
23use super::actions::{DebugAction, DebugSideEffect};
24use super::cell::inspect_cell;
25use super::config::DebugStyle;
26use super::state::DebugState;
27use super::table::{
28 ActionLogOverlay, DebugOverlay, DebugTableBuilder, DebugTableOverlay, DebugTableRow,
29};
30use super::widgets::{
31 debug_spans, dim_buffer, paint_snapshot, ActionLogStyle, CellPreviewWidget, DebugSyntaxStyle,
32 DebugTableStyle,
33};
34use super::DebugFreeze;
35
36use tui_dispatch_components::{
37 centered_rect, BaseStyle, LinesScroller, Modal, ModalBehavior, ModalProps, ModalStyle, Padding,
38 ScrollView, ScrollViewBehavior, ScrollViewProps, ScrollViewStyle,
39 ScrollbarStyle as ComponentScrollbarStyle, SelectionStyle, StatusBar, StatusBarItem,
40 StatusBarProps, StatusBarSection, StatusBarStyle, TreeBranchMode, TreeBranchStyle, TreeNode,
41 TreeNodeRender, TreeView, TreeViewBehavior, TreeViewProps, TreeViewStyle,
42};
43
44#[cfg(feature = "subscriptions")]
45use tui_dispatch_core::subscriptions::SubPauseHandle;
46#[cfg(feature = "tasks")]
47use tui_dispatch_core::tasks::TaskPauseHandle;
48use tui_dispatch_core::{Action, Component, EventKind};
49
50type StateSnapshotter = Box<dyn Fn(&dyn Any, &Path) -> crate::SnapshotResult<()> + 'static>;
51
52#[derive(Debug, Clone)]
53enum StateTreeAction {
54 Select(String),
55 Toggle(String, bool),
56}
57
58fn state_tree_select(id: &str) -> StateTreeAction {
59 StateTreeAction::Select(id.to_owned())
60}
61
62fn state_tree_toggle(id: &str, expanded: bool) -> StateTreeAction {
63 StateTreeAction::Toggle(id.to_owned(), expanded)
64}
65
66struct InlineValueStyle {
67 base: Style,
68 key: Style,
69 string: Style,
70 number: Style,
71 r#type: Style,
72}
73
74fn ron_value_spans(value: &str, style: &InlineValueStyle) -> Vec<Span<'static>> {
75 let chars: Vec<char> = value.chars().collect();
76 let mut spans = Vec::new();
77 let mut idx = 0;
78
79 while idx < chars.len() {
80 let current = chars[idx];
81 if current == '"' {
82 let start = idx;
83 idx += 1;
84 while idx < chars.len() {
85 if chars[idx] == '"' && chars.get(idx.saturating_sub(1)) != Some(&'\\') {
86 idx += 1;
87 break;
88 }
89 idx += 1;
90 }
91 let text: String = chars[start..idx].iter().collect();
92 spans.push(Span::styled(text, style.string));
93 continue;
94 }
95
96 if current.is_ascii_digit()
97 || (current == '-' && chars.get(idx + 1).is_some_and(|c| c.is_ascii_digit()))
98 {
99 let start = idx;
100 idx += 1;
101 while idx < chars.len() {
102 let c = chars[idx];
103 if c.is_ascii_digit() || matches!(c, '.' | '_' | 'e' | 'E' | '+' | '-') {
104 idx += 1;
105 } else {
106 break;
107 }
108 }
109 let text: String = chars[start..idx].iter().collect();
110 spans.push(Span::styled(text, style.number));
111 continue;
112 }
113
114 if current.is_alphanumeric() || current == '_' {
115 let start = idx;
116 idx += 1;
117 while idx < chars.len() {
118 let c = chars[idx];
119 if c.is_alphanumeric() || c == '_' {
120 idx += 1;
121 } else {
122 break;
123 }
124 }
125 let mut text: String = chars[start..idx].iter().collect();
126 let mut span_style = style.base;
127
128 if idx < chars.len() && chars[idx] == ':' {
129 text.push(':');
130 idx += 1;
131 span_style = style.key;
132 } else if text == "true" || text == "false" {
133 span_style = style.number;
134 } else if text.chars().next().is_some_and(|c| c.is_uppercase()) {
135 span_style = style.r#type;
136 }
137
138 spans.push(Span::styled(text, span_style));
139 continue;
140 }
141
142 spans.push(Span::styled(current.to_string(), style.base));
143 idx += 1;
144 }
145
146 spans
147}
148
149fn render_state_tree_node(
150 ctx: TreeNodeRender<'_, String, String>,
151 palette: &DebugStyle,
152) -> Line<'static> {
153 let section_style = Style::default()
155 .fg(palette.accent)
156 .add_modifier(Modifier::BOLD);
157 let key_style = Style::default().fg(palette.text_primary).bold();
159 let value_style = Style::default().fg(palette.text_primary);
161 let type_style = Style::default().fg(palette.neon_purple);
162 let string_style = Style::default().fg(palette.neon_green);
163 let number_style = Style::default().fg(palette.neon_amber);
164
165 let selection_patch = if ctx.is_selected {
166 Style::default().bg(palette.bg_highlight)
167 } else {
168 Style::default()
169 };
170
171 let content_width = ctx.available_width.max(1);
172 let mut spans = Vec::new();
173
174 if ctx.has_children {
176 let text = truncate_with_ellipsis(&ctx.node.value, content_width);
177 spans.push(Span::styled(text, section_style).patch_style(selection_patch));
178 return Line::from(spans);
179 }
180
181 if let Some((key, value)) = ctx.node.value.split_once(": ") {
183 let key_text = format!("{key}: ");
184 let key_len = key_text.chars().count();
185 spans.push(Span::styled(key_text, key_style).patch_style(selection_patch));
186
187 let remaining = content_width.saturating_sub(key_len);
188 if remaining > 0 {
189 let value_text = truncate_with_ellipsis(value, remaining);
190 let inline_style = InlineValueStyle {
191 base: value_style,
192 key: key_style,
193 string: string_style,
194 number: number_style,
195 r#type: type_style,
196 };
197 let value_spans = ron_value_spans(&value_text, &inline_style);
198 for span in value_spans {
199 spans.push(span.patch_style(selection_patch));
200 }
201 }
202
203 return Line::from(spans);
204 }
205
206 let text = truncate_with_ellipsis(&ctx.node.value, content_width);
208 spans.push(Span::styled(text, value_style).patch_style(selection_patch));
209
210 Line::from(spans)
211}
212
213#[derive(Clone, Copy, Debug, PartialEq, Eq)]
215pub enum BannerPosition {
216 Bottom,
217 Top,
218}
219
220impl BannerPosition {
221 pub fn toggle(self) -> Self {
223 match self {
224 Self::Bottom => Self::Top,
225 Self::Top => Self::Bottom,
226 }
227 }
228
229 fn label(self) -> &'static str {
230 match self {
231 Self::Bottom => "bar:bottom",
232 Self::Top => "bar:top",
233 }
234 }
235}
236
237#[derive(Debug, Clone, Copy)]
243pub struct ModalHint {
244 pub key: &'static str,
245 pub label: &'static str,
246}
247
248impl ModalHint {
249 pub const fn new(key: &'static str, label: &'static str) -> Self {
250 Self { key, label }
251 }
252}
253
254#[derive(Debug, Clone, Copy)]
256pub struct ModalHints {
257 pub left: &'static [ModalHint],
258 pub right: &'static [ModalHint],
259}
260
261const ACTION_LOG_HINTS: ModalHints = ModalHints {
263 left: &[
264 ModalHint::new("j/k", "scroll"),
265 ModalHint::new("g/G", "top/bottom"),
266 ModalHint::new("/", "filter"),
267 ],
268 right: &[
269 ModalHint::new("n/N", "next/prev"),
270 ModalHint::new("Enter", "details"),
271 ModalHint::new("Bksp", "close"),
272 ],
273};
274
275const ACTION_LOG_SEARCH_INPUT_HINTS: ModalHints = ModalHints {
277 left: &[
278 ModalHint::new("type", "query"),
279 ModalHint::new("Bksp", "edit"),
280 ],
281 right: &[
282 ModalHint::new("Enter", "done"),
283 ModalHint::new("Esc", "done"),
284 ],
285};
286
287const STATE_TREE_HINTS: ModalHints = ModalHints {
289 left: &[
290 ModalHint::new("j/k", "scroll"),
291 ModalHint::new("Space", "expand"),
292 ModalHint::new("Enter", "detail"),
293 ModalHint::new("/", "filter"),
294 ],
295 right: &[ModalHint::new("w", "save"), ModalHint::new("Bksp", "close")],
296};
297
298const ACTION_DETAIL_HINTS: ModalHints = ModalHints {
300 left: &[ModalHint::new("j/k", "scroll")],
301 right: &[ModalHint::new("Bksp", "back")],
302};
303
304const STATE_DETAIL_HINTS: ModalHints = ModalHints {
306 left: &[ModalHint::new("j/k", "scroll")],
307 right: &[ModalHint::new("Bksp", "back")],
308};
309
310const COMPONENTS_HINTS: ModalHints = ModalHints {
312 left: &[
313 ModalHint::new("j/k", "scroll"),
314 ModalHint::new("Space", "expand"),
315 ModalHint::new("Enter", "detail"),
316 ModalHint::new("/", "filter"),
317 ],
318 right: &[ModalHint::new("Bksp", "close")],
319};
320
321const SEARCH_INPUT_HINTS: ModalHints = ModalHints {
323 left: &[
324 ModalHint::new("type", "query"),
325 ModalHint::new("Bksp", "edit"),
326 ],
327 right: &[
328 ModalHint::new("Enter", "done"),
329 ModalHint::new("Esc", "done"),
330 ],
331};
332
333const COMPONENT_DETAIL_HINTS: ModalHints = ModalHints {
335 left: &[ModalHint::new("j/k", "scroll")],
336 right: &[ModalHint::new("Bksp", "back")],
337};
338
339const INSPECT_HINTS: ModalHints = ModalHints {
341 left: &[ModalHint::new("j/k", "scroll")],
342 right: &[ModalHint::new("Bksp", "close")],
343};
344
345pub struct DebugOutcome<A> {
347 pub consumed: bool,
349 pub queued_actions: Vec<A>,
351 pub needs_render: bool,
353}
354
355impl<A> DebugOutcome<A> {
356 fn ignored() -> Self {
357 Self {
358 consumed: false,
359 queued_actions: Vec::new(),
360 needs_render: false,
361 }
362 }
363
364 fn consumed(queued_actions: Vec<A>) -> Self {
365 Self {
366 consumed: true,
367 queued_actions,
368 needs_render: true,
369 }
370 }
371
372 pub fn dispatch_queued<F>(self, mut dispatch: F) -> Option<bool>
376 where
377 F: FnMut(A),
378 {
379 if !self.consumed {
380 return None;
381 }
382
383 for action in self.queued_actions {
384 dispatch(action);
385 }
386
387 Some(self.needs_render)
388 }
389}
390
391impl<A> Default for DebugOutcome<A> {
392 fn default() -> Self {
393 Self::ignored()
394 }
395}
396
397pub struct DebugLayer<A> {
426 toggle_key: KeyCode,
428 freeze: DebugFreeze<A>,
430 banner_position: BannerPosition,
432 style: DebugStyle,
434 active: bool,
436 action_log: ActionLog,
438 state_snapshot: Option<DebugTableOverlay>,
440 component_snapshotter: Option<Box<dyn Fn() -> Vec<super::table::ComponentSnapshot>>>,
442 state_snapshotter: Option<StateSnapshotter>,
444 state_tree_view: TreeView<String>,
446 state_tree_nodes: Vec<TreeNode<String, String>>,
448 state_tree_selected: Option<String>,
450 state_tree_expanded: HashSet<String>,
452 state_search_query: String,
454 state_search_active: bool,
456 state_tree_filtered: Vec<TreeNode<String, String>>,
458 components_tree_view: TreeView<String>,
460 components_tree_nodes: Vec<TreeNode<String, String>>,
462 components_tree_selected: Option<String>,
464 components_tree_expanded: HashSet<String>,
466 components_search_query: String,
468 components_search_active: bool,
470 components_tree_filtered: Vec<TreeNode<String, String>>,
472 table_scroll: super::scroll_state::ScrollState,
474 detail_scroll: super::scroll_state::ScrollState,
476 #[cfg(feature = "tasks")]
478 task_handle: Option<TaskPauseHandle<A>>,
479 #[cfg(feature = "subscriptions")]
481 sub_handle: Option<SubPauseHandle>,
482}
483
484impl<A> std::fmt::Debug for DebugLayer<A> {
485 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
486 f.debug_struct("DebugLayer")
487 .field("toggle_key", &self.toggle_key)
488 .field("active", &self.active)
489 .field("enabled", &self.freeze.enabled)
490 .field("has_snapshot", &self.freeze.snapshot.is_some())
491 .field("has_state_snapshot", &self.state_snapshot.is_some())
492 .field("has_state_snapshotter", &self.state_snapshotter.is_some())
493 .field(
494 "has_component_snapshotter",
495 &self.component_snapshotter.is_some(),
496 )
497 .field("state_tree_nodes", &self.state_tree_nodes.len())
498 .field("state_tree_selected", &self.state_tree_selected)
499 .field("banner_position", &self.banner_position)
500 .field("table_scroll", &self.table_scroll)
501 .field("detail_scroll", &self.detail_scroll)
502 .field("queued_actions", &self.freeze.queued_actions.len())
503 .finish()
504 }
505}
506
507impl<A: Action> DebugLayer<A> {
508 pub fn new(toggle_key: KeyCode) -> Self {
518 Self {
519 toggle_key,
520 freeze: DebugFreeze::new(),
521 banner_position: BannerPosition::Bottom,
522 style: DebugStyle::default(),
523 active: true,
524 action_log: ActionLog::new(ActionLogConfig::with_capacity(100)),
525 state_snapshot: None,
526 component_snapshotter: None,
527 state_snapshotter: None,
528 state_tree_view: TreeView::new(),
529 state_tree_nodes: Vec::new(),
530 state_tree_selected: None,
531 state_tree_expanded: HashSet::new(),
532 state_search_query: String::new(),
533 state_search_active: false,
534 state_tree_filtered: Vec::new(),
535 components_tree_view: TreeView::new(),
536 components_tree_nodes: Vec::new(),
537 components_tree_selected: None,
538 components_tree_expanded: HashSet::new(),
539 components_search_query: String::new(),
540 components_search_active: false,
541 components_tree_filtered: Vec::new(),
542 table_scroll: super::scroll_state::ScrollState::new(),
543 detail_scroll: super::scroll_state::ScrollState::new(),
544 #[cfg(feature = "tasks")]
545 task_handle: None,
546 #[cfg(feature = "subscriptions")]
547 sub_handle: None,
548 }
549 }
550
551 pub fn simple() -> Self {
553 Self::new(KeyCode::F(12))
554 }
555
556 pub fn simple_with_toggle_key(toggle_key: KeyCode) -> Self {
558 Self::new(toggle_key)
559 }
560
561 pub fn active(mut self, active: bool) -> Self {
565 self.active = active;
566 self
567 }
568
569 pub fn with_banner_position(mut self, position: BannerPosition) -> Self {
571 self.banner_position = position;
572 self
573 }
574
575 #[cfg(feature = "tasks")]
580 pub fn with_task_manager(mut self, tasks: &tui_dispatch_core::tasks::TaskManager<A>) -> Self {
581 self.task_handle = Some(tasks.pause_handle());
582 self
583 }
584
585 #[cfg(feature = "subscriptions")]
589 pub fn with_subscriptions(
590 mut self,
591 subs: &tui_dispatch_core::subscriptions::Subscriptions<A>,
592 ) -> Self {
593 self.sub_handle = Some(subs.pause_handle());
594 self
595 }
596
597 pub fn with_action_log_capacity(mut self, capacity: usize) -> Self {
599 self.action_log = ActionLog::new(ActionLogConfig::with_capacity(capacity));
600 self
601 }
602
603 pub fn with_action_log_config(mut self, config: ActionLogConfig) -> Self {
605 self.action_log = ActionLog::new(config);
606 self
607 }
608
609 pub fn with_action_log_filter(mut self, filter: ActionLoggerConfig) -> Self {
611 let capacity = self.action_log.config().capacity;
612 self.action_log = ActionLog::new(ActionLogConfig::new(capacity, filter));
613 self
614 }
615
616 pub fn with_style(mut self, style: DebugStyle) -> Self {
618 self.style = style;
619 self
620 }
621
622 pub fn with_state_snapshotter<S, F>(mut self, snapshotter: F) -> Self
624 where
625 S: DebugState + 'static,
626 F: Fn(&S, &Path) -> crate::SnapshotResult<()> + 'static,
627 {
628 self.state_snapshotter = Some(Box::new(move |state, path| {
629 let state = state.downcast_ref::<S>().ok_or_else(|| {
630 crate::SnapshotError::Io(io::Error::new(
631 io::ErrorKind::InvalidInput,
632 "debug state snapshot type mismatch",
633 ))
634 })?;
635 snapshotter(state, path)
636 }));
637 self
638 }
639
640 pub fn with_state_snapshots<S>(self) -> Self
642 where
643 S: DebugState + Serialize + 'static,
644 {
645 self.with_state_snapshotter::<S, _>(|state, path| crate::save_json(path, state))
646 }
647
648 pub fn with_component_snapshotter<F>(mut self, snapshotter: F) -> Self
653 where
654 F: Fn() -> Vec<super::table::ComponentSnapshot> + 'static,
655 {
656 self.component_snapshotter = Some(Box::new(snapshotter));
657 self
658 }
659
660 pub fn with_component_host<S, A2, Id, Ctx>(
664 self,
665 host: &tui_dispatch_components::ComponentHost<S, A2, Id, Ctx>,
666 ) -> Self
667 where
668 S: 'static,
669 A2: 'static,
670 Id: tui_dispatch_core::ComponentId + 'static,
671 Ctx: tui_dispatch_core::BindingContext + 'static,
672 {
673 let host = host.clone();
674 self.with_component_snapshotter(move || {
675 host.mounted_components()
676 .iter()
677 .map(super::table::ComponentSnapshot::from_mounted_info)
678 .collect()
679 })
680 }
681
682 pub fn is_active(&self) -> bool {
684 self.active
685 }
686
687 pub fn is_enabled(&self) -> bool {
689 self.active && self.freeze.enabled
690 }
691
692 pub fn toggle_enabled(&mut self) -> Option<DebugSideEffect<A>> {
696 if !self.active {
697 return None;
698 }
699 self.toggle()
700 }
701
702 pub fn set_enabled(&mut self, enabled: bool) -> Option<DebugSideEffect<A>> {
706 if !self.active || enabled == self.freeze.enabled {
707 return None;
708 }
709 self.toggle()
710 }
711
712 pub fn set_banner_position(&mut self, position: BannerPosition) {
714 if self.banner_position != position {
715 self.banner_position = position;
716 if self.freeze.enabled {
717 self.freeze.request_capture();
718 }
719 }
720 }
721
722 pub fn is_state_overlay_visible(&self) -> bool {
724 matches!(self.freeze.overlay, Some(DebugOverlay::State(_)))
725 }
726
727 pub fn freeze(&self) -> &DebugFreeze<A> {
729 &self.freeze
730 }
731
732 pub fn freeze_mut(&mut self) -> &mut DebugFreeze<A> {
734 &mut self.freeze
735 }
736
737 pub fn log_action<T: tui_dispatch_core::ActionParams>(&mut self, action: &T) {
741 if self.active {
742 self.action_log.log(action);
743 }
744 }
745
746 pub fn action_log(&self) -> &ActionLog {
748 &self.action_log
749 }
750
751 pub fn render<F>(&mut self, frame: &mut Frame, render_fn: F)
756 where
757 F: FnOnce(&mut Frame, Rect),
758 {
759 self.render_with_state(frame, |frame, area, _wants_state| {
760 render_fn(frame, area);
761 None
762 });
763 }
764
765 pub fn render_with_state<F>(&mut self, frame: &mut Frame, render_fn: F)
771 where
772 F: FnOnce(&mut Frame, Rect, bool) -> Option<DebugTableOverlay>,
773 {
774 let screen = frame.area();
775
776 if !self.active || !self.freeze.enabled {
778 let _ = render_fn(frame, screen, false);
779 return;
780 }
781
782 let (app_area, banner_area) = self.split_for_banner(screen);
784
785 if self.freeze.pending_capture || self.freeze.snapshot.is_none() {
786 let state_snapshot = render_fn(frame, app_area, true);
788 self.state_snapshot = state_snapshot;
789 if let Some(ref table) = self.state_snapshot {
790 if self.is_state_overlay_visible() {
791 self.set_state_overlay(table.clone());
792 }
793 }
794 let buffer_clone = frame.buffer_mut().clone();
795 self.freeze.capture(&buffer_clone);
796 } else if let Some(ref snapshot) = self.freeze.snapshot {
797 paint_snapshot(frame, snapshot);
799 }
800
801 self.render_debug_overlay(frame, app_area, banner_area);
803 }
804
805 pub fn render_state<S: DebugState, F>(&mut self, frame: &mut Frame, state: &S, render_fn: F)
809 where
810 F: FnOnce(&mut Frame, Rect),
811 {
812 self.render_with_state(frame, |frame, area, wants_state| {
813 render_fn(frame, area);
814 if wants_state {
815 Some(state.build_debug_table("Application State"))
816 } else {
817 None
818 }
819 });
820 }
821
822 pub fn split_area(&self, area: Rect) -> (Rect, Rect) {
824 if !self.freeze.enabled {
825 return (area, Rect::ZERO);
826 }
827 self.split_for_banner(area)
828 }
829
830 pub fn intercepts(&mut self, event: &tui_dispatch_core::EventKind) -> bool {
844 self.intercepts_with_effects(event).is_some()
845 }
846
847 pub fn handle_event(&mut self, event: &tui_dispatch_core::EventKind) -> DebugOutcome<A> {
849 self.handle_event_internal::<()>(event, None)
850 }
851
852 pub fn handle_event_with_state<S: DebugState + 'static>(
854 &mut self,
855 event: &tui_dispatch_core::EventKind,
856 state: &S,
857 ) -> DebugOutcome<A> {
858 self.handle_event_internal(event, Some(state))
859 }
860
861 pub fn intercepts_with_effects(
865 &mut self,
866 event: &tui_dispatch_core::EventKind,
867 ) -> Option<Vec<DebugSideEffect<A>>> {
868 self.intercepts_with_effects_internal::<()>(event, None)
869 }
870
871 pub fn intercepts_with_effects_and_state<S: DebugState + 'static>(
875 &mut self,
876 event: &tui_dispatch_core::EventKind,
877 state: &S,
878 ) -> Option<Vec<DebugSideEffect<A>>> {
879 self.intercepts_with_effects_internal(event, Some(state))
880 }
881
882 pub fn intercepts_with_state<S: DebugState + 'static>(
884 &mut self,
885 event: &tui_dispatch_core::EventKind,
886 state: &S,
887 ) -> bool {
888 self.intercepts_with_effects_internal(event, Some(state))
889 .is_some()
890 }
891
892 fn handle_event_internal<S: DebugState + 'static>(
893 &mut self,
894 event: &tui_dispatch_core::EventKind,
895 state: Option<&S>,
896 ) -> DebugOutcome<A> {
897 let effects = self.intercepts_with_effects_internal(event, state);
898 let Some(effects) = effects else {
899 return DebugOutcome::ignored();
900 };
901
902 let mut queued_actions = Vec::new();
903 for effect in effects {
904 if let DebugSideEffect::ProcessQueuedActions(actions) = effect {
905 queued_actions.extend(actions);
906 }
907 }
908
909 DebugOutcome::consumed(queued_actions)
910 }
911
912 fn intercepts_with_effects_internal<S: DebugState + 'static>(
913 &mut self,
914 event: &tui_dispatch_core::EventKind,
915 state: Option<&S>,
916 ) -> Option<Vec<DebugSideEffect<A>>> {
917 if !self.active {
918 return None;
919 }
920
921 use tui_dispatch_core::EventKind;
922
923 match event {
924 EventKind::Key(key) => self.handle_key_event(*key, state),
925 EventKind::Mouse(mouse) => {
926 if !self.freeze.enabled {
927 return None;
928 }
929
930 if !self.freeze.mouse_capture_enabled {
933 return None;
934 }
935
936 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
938 let effect = self.handle_action(DebugAction::InspectCell {
939 column: mouse.column,
940 row: mouse.row,
941 });
942 return Some(effect.into_iter().collect());
943 }
944
945 Some(vec![])
947 }
948 EventKind::Scroll {
949 delta,
950 column,
951 row,
952 modifiers,
953 } => {
954 if !self.freeze.enabled {
955 return None;
956 }
957
958 match self.freeze.overlay.as_ref() {
959 Some(DebugOverlay::ActionLog(_)) => {
960 let action = if *delta > 0 {
961 DebugAction::ActionLogScrollUp
962 } else {
963 DebugAction::ActionLogScrollDown
964 };
965 self.handle_action(action);
966 }
967 Some(DebugOverlay::State(_)) => {
968 self.sync_state_tree_state();
969 let filtered = &self.state_tree_filtered;
970 let selected_id = self.state_tree_selected.as_ref();
971 let expanded_ids = &self.state_tree_expanded;
972 let style = self.state_tree_style();
973 let palette = &self.style;
974 let render_node =
975 |ctx: TreeNodeRender<'_, String, String>| -> Line<'static> {
976 render_state_tree_node(ctx, palette)
977 };
978 let props = Self::build_tree_props(
979 filtered,
980 selected_id,
981 expanded_ids,
982 style,
983 &render_node,
984 );
985 let actions: Vec<_> = self
986 .state_tree_view
987 .handle_event(
988 &EventKind::Scroll {
989 delta: *delta,
990 column: *column,
991 row: *row,
992 modifiers: *modifiers,
993 },
994 props,
995 )
996 .into_iter()
997 .collect();
998 if !actions.is_empty() {
999 self.apply_state_tree_actions(actions);
1000 }
1001 }
1002 Some(DebugOverlay::Inspect(table)) => {
1003 if *delta > 0 {
1004 self.table_scroll.scroll_up();
1005 } else {
1006 self.table_scroll.scroll_down(table.rows.len());
1007 }
1008 }
1009 Some(DebugOverlay::ActionDetail(detail)) => {
1010 let params_lines = self.detail_params_lines(detail);
1011 if *delta > 0 {
1012 self.detail_scroll.scroll_up();
1013 } else {
1014 self.detail_scroll.scroll_down(params_lines.len());
1015 }
1016 }
1017 Some(DebugOverlay::StateDetail(detail)) => {
1018 let line_count = self.state_detail_lines(detail).len();
1019 if *delta > 0 {
1020 self.detail_scroll.scroll_up();
1021 } else {
1022 self.detail_scroll.scroll_down(line_count);
1023 }
1024 }
1025 Some(DebugOverlay::ComponentDetail(detail)) => {
1026 let line_count = self.component_detail_lines(detail).len();
1027 if *delta > 0 {
1028 self.detail_scroll.scroll_up();
1029 } else {
1030 self.detail_scroll.scroll_down(line_count);
1031 }
1032 }
1033 Some(DebugOverlay::Components(_)) => {
1034 self.sync_components_tree_state();
1035 let filtered = &self.components_tree_filtered;
1036 let selected_id = self.components_tree_selected.as_ref();
1037 let expanded_ids = &self.components_tree_expanded;
1038 let style = self.state_tree_style();
1039 let palette = &self.style;
1040 let render_node =
1041 |ctx: TreeNodeRender<'_, String, String>| -> Line<'static> {
1042 render_state_tree_node(ctx, palette)
1043 };
1044 let props = Self::build_tree_props(
1045 filtered,
1046 selected_id,
1047 expanded_ids,
1048 style,
1049 &render_node,
1050 );
1051 let actions: Vec<_> = self
1052 .components_tree_view
1053 .handle_event(
1054 &EventKind::Scroll {
1055 delta: *delta,
1056 column: *column,
1057 row: *row,
1058 modifiers: *modifiers,
1059 },
1060 props,
1061 )
1062 .into_iter()
1063 .collect();
1064 if !actions.is_empty() {
1065 self.apply_components_tree_actions(actions);
1066 }
1067 }
1068 _ => {}
1069 }
1070
1071 Some(vec![])
1072 }
1073 EventKind::Resize(_, _) | EventKind::Tick => None,
1075 }
1076 }
1077
1078 pub fn show_state_overlay<S: DebugState>(&mut self, state: &S) {
1080 let table = state.build_debug_table("Application State");
1081 self.set_state_overlay(table);
1082 }
1083
1084 pub fn show_action_log(&mut self) {
1086 let overlay = ActionLogOverlay::from_log(&self.action_log, "Action Log");
1087 self.freeze.set_overlay(DebugOverlay::ActionLog(overlay));
1088 }
1089
1090 pub fn show_components(&mut self) {
1092 use super::table::{ComponentSnapshot, ComponentsOverlay};
1093 let components: Vec<ComponentSnapshot> = self
1094 .component_snapshotter
1095 .as_ref()
1096 .map(|f| f())
1097 .unwrap_or_default();
1098 let overlay = ComponentsOverlay::new("Components", components);
1099 self.set_components_overlay(overlay);
1100 }
1101
1102 pub fn queue_action(&mut self, action: A) {
1104 self.freeze.queue(action);
1105 }
1106
1107 pub fn take_queued_actions(&mut self) -> Vec<A> {
1112 std::mem::take(&mut self.freeze.queued_actions)
1113 }
1114
1115 fn set_state_overlay(&mut self, table: DebugTableOverlay) {
1120 let is_new_overlay = !matches!(
1121 self.freeze.overlay,
1122 Some(DebugOverlay::State(_)) | Some(DebugOverlay::StateDetail(_))
1123 );
1124 if is_new_overlay {
1125 self.table_scroll.reset();
1126 self.state_tree_view = TreeView::new();
1127 self.state_tree_selected = None;
1128 self.state_tree_expanded.clear();
1129 self.state_search_query.clear();
1130 self.state_search_active = false;
1131 }
1132
1133 self.state_tree_nodes = self.build_state_tree_nodes(&table);
1134 if is_new_overlay {
1135 self.state_tree_expanded = self
1136 .state_tree_nodes
1137 .iter()
1138 .map(|node| node.id.clone())
1139 .collect();
1140 }
1141 self.refilter_state_tree();
1142 self.sync_state_tree_state();
1143 self.state_snapshot = Some(table.clone());
1144 self.freeze.set_overlay(DebugOverlay::State(table));
1145 }
1146
1147 fn build_state_tree_nodes(&self, table: &DebugTableOverlay) -> Vec<TreeNode<String, String>> {
1148 let mut sections: Vec<TreeNode<String, String>> = Vec::new();
1149 let mut current: Option<TreeNode<String, String>> = None;
1150 let mut section_index = 0usize;
1151 let mut entry_index = 0usize;
1152 let mut current_section_index = 0usize;
1153
1154 for row in &table.rows {
1155 match row {
1156 DebugTableRow::Section(title) => {
1157 if let Some(section) = current.take() {
1158 sections.push(section);
1159 }
1160 current_section_index = section_index;
1161 entry_index = 0;
1162 let id = format!("section:{section_index}:{title}");
1163 section_index = section_index.saturating_add(1);
1164 current = Some(TreeNode::with_children(id, title.clone(), Vec::new()));
1165 }
1166 DebugTableRow::Entry { key, value } => {
1167 if current.is_none() {
1168 current_section_index = section_index;
1169 entry_index = 0;
1170 let id = format!("section:{section_index}:State");
1171 section_index = section_index.saturating_add(1);
1172 current =
1173 Some(TreeNode::with_children(id, "State".to_string(), Vec::new()));
1174 }
1175 if let Some(section) = current.as_mut() {
1176 let entry_id = format!("entry:{current_section_index}:{entry_index}:{key}");
1177 entry_index = entry_index.saturating_add(1);
1178 section
1179 .children
1180 .push(TreeNode::new(entry_id, format!("{key}: {value}")));
1181 }
1182 }
1183 }
1184 }
1185
1186 if let Some(section) = current.take() {
1187 sections.push(section);
1188 }
1189
1190 sections
1191 }
1192
1193 fn build_state_entry_detail(
1195 &self,
1196 selected_id: &str,
1197 ) -> Option<super::table::StateEntryDetail> {
1198 if !selected_id.starts_with("entry:") {
1200 return None;
1201 }
1202
1203 for section_node in &self.state_tree_nodes {
1205 for child in §ion_node.children {
1206 if child.id == selected_id {
1207 let (key, value) = child.value.split_once(": ").unwrap_or((&child.value, ""));
1209 return Some(super::table::StateEntryDetail {
1210 section: section_node.value.clone(),
1211 key: key.to_string(),
1212 value: value.to_string(),
1213 });
1214 }
1215 }
1216 }
1217 None
1218 }
1219
1220 fn build_components_tree_nodes(
1221 components: &[super::table::ComponentSnapshot],
1222 ) -> Vec<TreeNode<String, String>> {
1223 components
1224 .iter()
1225 .enumerate()
1226 .map(|(i, comp)| {
1227 let id = format!("comp:{i}");
1228 let bound = comp
1229 .bound_id
1230 .as_deref()
1231 .map(|b| format!(" \u{2192} {b}")) .unwrap_or_default();
1233 let area_str = comp
1234 .last_area
1235 .map(|a| format!(" [{}x{} @ ({},{})]", a.width, a.height, a.x, a.y))
1236 .unwrap_or_default();
1237 let label = format!("{}{}{}", comp.type_name, bound, area_str);
1238
1239 let children: Vec<TreeNode<String, String>> = comp
1240 .debug_entries
1241 .iter()
1242 .enumerate()
1243 .map(|(j, (k, v))| {
1244 TreeNode::new(format!("entry:{i}:{j}:{k}"), format!("{k}: {v}"))
1245 })
1246 .collect();
1247
1248 TreeNode::with_children(id, label, children)
1249 })
1250 .collect()
1251 }
1252
1253 fn set_components_overlay(&mut self, overlay: super::table::ComponentsOverlay) {
1254 let is_new = !matches!(
1255 self.freeze.overlay,
1256 Some(DebugOverlay::Components(_)) | Some(DebugOverlay::ComponentDetail(_))
1257 );
1258 if is_new {
1259 self.components_tree_view = TreeView::new();
1260 self.components_tree_selected = None;
1261 self.components_tree_expanded.clear();
1262 self.components_search_query.clear();
1263 self.components_search_active = false;
1264 }
1265
1266 self.components_tree_nodes = Self::build_components_tree_nodes(&overlay.components);
1267
1268 if is_new {
1269 self.components_tree_expanded = self
1271 .components_tree_nodes
1272 .iter()
1273 .map(|node| node.id.clone())
1274 .collect();
1275 }
1276
1277 self.refilter_components_tree();
1278 self.freeze.set_overlay(DebugOverlay::Components(overlay));
1279 }
1280
1281 fn refilter_state_tree(&mut self) {
1282 self.state_tree_filtered =
1283 filter_tree_nodes(&self.state_tree_nodes, &self.state_search_query);
1284
1285 let filtered = &self.state_tree_filtered;
1286 let selected_valid = self
1287 .state_tree_selected
1288 .as_deref()
1289 .map(|id| Self::tree_contains_id(filtered, id))
1290 .unwrap_or(false);
1291 if !selected_valid {
1292 self.state_tree_selected = filtered.first().map(|node| node.id.clone());
1293 }
1294 }
1295
1296 fn refilter_components_tree(&mut self) {
1297 self.components_tree_filtered =
1298 filter_tree_nodes(&self.components_tree_nodes, &self.components_search_query);
1299
1300 let filtered = &self.components_tree_filtered;
1301 let selected_valid = self
1302 .components_tree_selected
1303 .as_deref()
1304 .map(|id| Self::tree_contains_id(filtered, id))
1305 .unwrap_or(false);
1306 if !selected_valid {
1307 self.components_tree_selected = filtered.first().map(|node| node.id.clone());
1308 }
1309 }
1310
1311 fn sync_state_tree_state(&mut self) {
1312 let nodes = &self.state_tree_nodes;
1313 self.state_tree_expanded
1314 .retain(|id| Self::tree_contains_id(nodes, id));
1315
1316 let filtered = &self.state_tree_filtered;
1317 let selected_valid = self
1318 .state_tree_selected
1319 .as_deref()
1320 .map(|id| Self::tree_contains_id(filtered, id))
1321 .unwrap_or(false);
1322
1323 if !selected_valid {
1324 self.state_tree_selected = filtered.first().map(|node| node.id.clone());
1325 }
1326 }
1327
1328 fn tree_contains_id(nodes: &[TreeNode<String, String>], id: &str) -> bool {
1329 nodes
1330 .iter()
1331 .any(|node| node.id == id || Self::tree_contains_id(&node.children, id))
1332 }
1333
1334 fn state_tree_style(&self) -> TreeViewStyle {
1335 TreeViewStyle {
1336 base: BaseStyle {
1337 border: None,
1338 padding: Padding::default(),
1339 bg: Some(self.style.overlay_bg),
1340 fg: Some(self.style.text_primary),
1341 },
1342 selection: SelectionStyle::disabled(),
1343 scrollbar: self.component_scrollbar_style(),
1344 branches: TreeBranchStyle {
1345 mode: TreeBranchMode::Branch,
1346 connector_style: Style::default().fg(self.style.text_secondary),
1347 ..Default::default()
1348 },
1349 }
1350 }
1351
1352 fn build_tree_props<'a>(
1353 nodes: &'a [TreeNode<String, String>],
1354 selected_id: Option<&'a String>,
1355 expanded_ids: &'a HashSet<String>,
1356 style: TreeViewStyle,
1357 render_node: &'a dyn Fn(TreeNodeRender<'_, String, String>) -> Line<'static>,
1358 ) -> TreeViewProps<'a, String, String, StateTreeAction> {
1359 TreeViewProps {
1360 nodes,
1361 selected_id,
1362 expanded_ids,
1363 is_focused: true,
1364 style,
1365 behavior: TreeViewBehavior::default(),
1366 measure_node: None,
1367 column_padding: 0,
1368 on_select: Rc::new(|id| state_tree_select(id)),
1369 on_toggle: Rc::new(|id, expanded| state_tree_toggle(id, expanded)),
1370 render_node,
1371 }
1372 }
1373
1374 fn apply_state_tree_actions(&mut self, actions: impl IntoIterator<Item = StateTreeAction>) {
1375 for action in actions {
1376 match action {
1377 StateTreeAction::Select(id) => {
1378 self.state_tree_selected = Some(id);
1379 }
1380 StateTreeAction::Toggle(id, expand) => {
1381 if expand {
1382 self.state_tree_expanded.insert(id);
1383 } else {
1384 self.state_tree_expanded.remove(&id);
1385 }
1386 }
1387 }
1388 }
1389 }
1390
1391 fn apply_components_tree_actions(
1392 &mut self,
1393 actions: impl IntoIterator<Item = StateTreeAction>,
1394 ) {
1395 for action in actions {
1396 match action {
1397 StateTreeAction::Select(id) => {
1398 self.components_tree_selected = Some(id);
1399 }
1400 StateTreeAction::Toggle(id, expand) => {
1401 if expand {
1402 self.components_tree_expanded.insert(id);
1403 } else {
1404 self.components_tree_expanded.remove(&id);
1405 }
1406 }
1407 }
1408 }
1409 }
1410
1411 fn save_state_snapshot<S: DebugState + 'static>(&mut self, state: Option<&S>) {
1412 let Some(state) = state else {
1413 self.freeze
1414 .set_message("State unavailable: call render_state() first");
1415 return;
1416 };
1417
1418 let path = self.state_snapshot_path();
1419
1420 let result = if let Some(snapshotter) = self.state_snapshotter.as_ref() {
1421 snapshotter(state as &dyn Any, &path)
1422 } else {
1423 let snapshot = DebugStateSnapshot::from_state(state, "Application State");
1424 crate::save_json(&path, &snapshot)
1425 };
1426
1427 match result {
1428 Ok(()) => self
1429 .freeze
1430 .set_message(format!("Saved state: {}", path.display())),
1431 Err(err) => self
1432 .freeze
1433 .set_message(format!("State save failed: {err:?}")),
1434 }
1435 }
1436
1437 fn state_snapshot_path(&self) -> PathBuf {
1438 let timestamp = SystemTime::now()
1439 .duration_since(UNIX_EPOCH)
1440 .unwrap_or_default()
1441 .as_millis();
1442 PathBuf::from(format!("debug-state-{timestamp}.json"))
1443 }
1444
1445 fn overlay_modal_area(&self, app_area: Rect) -> Rect {
1446 let modal_width = (app_area.width * 80 / 100)
1447 .clamp(40, 160)
1448 .min(app_area.width);
1449 let modal_height = (app_area.height * 80 / 100)
1450 .clamp(12, 50)
1451 .min(app_area.height);
1452 centered_rect(modal_width, modal_height, app_area)
1453 }
1454
1455 fn overlay_modal_style(&self) -> ModalStyle {
1456 ModalStyle {
1457 dim_factor: 0.0,
1458 base: BaseStyle {
1459 border: None,
1460 padding: Padding::default(), bg: Some(self.style.overlay_bg),
1462 fg: None,
1463 },
1464 }
1465 }
1466
1467 fn render_overlay_container<F>(
1468 &self,
1469 frame: &mut Frame,
1470 app_area: Rect,
1471 title: &str,
1472 hints: Option<ModalHints>,
1473 footer_center_items: Option<Vec<StatusBarItem<'static>>>,
1474 mut render_body: F,
1475 ) where
1476 F: FnMut(&mut Frame, Rect),
1477 {
1478 let modal_area = self.overlay_modal_area(app_area);
1479 let mut modal = Modal::new();
1480 let mut render_content = |frame: &mut Frame, content_area: Rect| {
1481 if content_area.height == 0 || content_area.width == 0 {
1482 return;
1483 }
1484
1485 let content_area = self.render_overlay_title(frame, content_area, title);
1487 if content_area.height == 0 || content_area.width == 0 {
1488 return;
1489 }
1490
1491 let content_area = if let Some(hints) = hints {
1493 self.render_overlay_footer(
1494 frame,
1495 content_area,
1496 hints,
1497 footer_center_items.as_deref(),
1498 )
1499 } else {
1500 content_area
1501 };
1502 if content_area.height == 0 || content_area.width == 0 {
1503 return;
1504 }
1505
1506 let body_area = Rect {
1508 x: content_area.x.saturating_add(1),
1509 y: content_area.y,
1510 width: content_area.width.saturating_sub(2),
1511 height: content_area.height,
1512 };
1513 if body_area.width == 0 {
1514 return;
1515 }
1516
1517 render_body(frame, body_area);
1518 };
1519
1520 modal.render(
1521 frame,
1522 app_area,
1523 ModalProps {
1524 is_open: true,
1525 is_focused: false,
1526 area: modal_area,
1527 style: self.overlay_modal_style(),
1528 behavior: ModalBehavior::default(),
1529 on_close: Rc::new(|| ()),
1530 render_content: &mut render_content,
1531 },
1532 );
1533 }
1534
1535 fn render_overlay_title(&self, frame: &mut Frame, area: Rect, title: &str) -> Rect {
1536 if area.height == 0 || area.width == 0 {
1537 return area;
1538 }
1539
1540 use ratatui::text::Span;
1541
1542 let accent = self.style.accent;
1543 let title_style = Style::default().fg(accent).add_modifier(Modifier::BOLD);
1544 let title_items = [StatusBarItem::span(Span::styled(
1545 format!(" {title} "),
1546 title_style,
1547 ))];
1548 let title_bar_style = StatusBarStyle {
1549 base: BaseStyle {
1550 border: None,
1551 padding: Padding::default(),
1552 bg: None,
1553 fg: None,
1554 },
1555 text: Style::default().fg(accent),
1556 hint_key: title_style,
1557 hint_label: Style::default().fg(accent),
1558 separator: Style::default().fg(accent),
1559 };
1560 let title_area = Rect { height: 1, ..area };
1561
1562 let mut status_bar = StatusBar::new();
1563 <StatusBar as tui_dispatch_core::Component<()>>::render(
1564 &mut status_bar,
1565 frame,
1566 title_area,
1567 StatusBarProps {
1568 left: StatusBarSection::empty(),
1569 center: StatusBarSection::items(&title_items),
1570 right: StatusBarSection::empty(),
1571 style: title_bar_style,
1572 is_focused: false,
1573 },
1574 );
1575
1576 Rect {
1577 x: area.x,
1578 y: area.y.saturating_add(title_area.height),
1579 width: area.width,
1580 height: area.height.saturating_sub(title_area.height),
1581 }
1582 }
1583
1584 fn render_overlay_footer(
1587 &self,
1588 frame: &mut Frame,
1589 area: Rect,
1590 hints: ModalHints,
1591 center_items: Option<&[StatusBarItem<'static>]>,
1592 ) -> Rect {
1593 if area.height < 2 || area.width == 0 {
1594 return area;
1595 }
1596
1597 let footer_area = Rect {
1598 x: area.x,
1599 y: area.y.saturating_add(area.height.saturating_sub(1)),
1600 width: area.width,
1601 height: 1,
1602 };
1603
1604 let key_style = Style::default()
1606 .fg(self.style.bg_deep)
1607 .bg(self.style.text_secondary)
1608 .add_modifier(Modifier::BOLD);
1609 let label_style = Style::default().fg(self.style.text_secondary);
1610
1611 let mut left_items: Vec<StatusBarItem<'static>> = Vec::new();
1613 for hint in hints.left {
1614 left_items.push(StatusBarItem::span(Span::styled(
1615 format!(" {} ", hint.key),
1616 key_style,
1617 )));
1618 left_items.push(StatusBarItem::span(Span::styled(
1619 format!(" {} ", hint.label),
1620 label_style,
1621 )));
1622 }
1623
1624 let mut right_items: Vec<StatusBarItem<'static>> = Vec::new();
1626 for hint in hints.right {
1627 right_items.push(StatusBarItem::span(Span::styled(
1628 format!(" {} ", hint.key),
1629 key_style,
1630 )));
1631 right_items.push(StatusBarItem::span(Span::styled(
1632 format!(" {} ", hint.label),
1633 label_style,
1634 )));
1635 }
1636
1637 let footer_style = StatusBarStyle {
1638 base: BaseStyle {
1639 border: None,
1640 padding: Padding::default(),
1641 bg: Some(self.style.overlay_bg_dark),
1642 fg: None,
1643 },
1644 text: label_style,
1645 hint_key: key_style,
1646 hint_label: label_style,
1647 separator: label_style,
1648 };
1649
1650 let left = StatusBarSection::items(&left_items).with_separator("");
1651 let center = if let Some(items) = center_items {
1652 StatusBarSection::items(items).with_separator("")
1653 } else {
1654 StatusBarSection::empty()
1655 };
1656 let right = if right_items.is_empty() {
1657 StatusBarSection::empty()
1658 } else {
1659 StatusBarSection::items(&right_items).with_separator("")
1660 };
1661
1662 let mut status_bar = StatusBar::new();
1663 <StatusBar as tui_dispatch_core::Component<()>>::render(
1664 &mut status_bar,
1665 frame,
1666 footer_area,
1667 StatusBarProps {
1668 left,
1669 center,
1670 right,
1671 style: footer_style,
1672 is_focused: false,
1673 },
1674 );
1675
1676 Rect {
1678 x: area.x,
1679 y: area.y,
1680 width: area.width,
1681 height: area.height.saturating_sub(1),
1682 }
1683 }
1684
1685 fn component_scrollbar_style(&self) -> ComponentScrollbarStyle {
1686 let style = &self.style.scrollbar;
1687 ComponentScrollbarStyle {
1688 thumb: style.thumb,
1689 track: style.track,
1690 begin: style.begin,
1691 end: style.end,
1692 thumb_symbol: style.thumb_symbol,
1693 track_symbol: style.track_symbol,
1694 begin_symbol: style.begin_symbol,
1695 end_symbol: style.end_symbol,
1696 }
1697 }
1698
1699 fn handle_key_event<S: DebugState + 'static>(
1700 &mut self,
1701 key: KeyEvent,
1702 state: Option<&S>,
1703 ) -> Option<Vec<DebugSideEffect<A>>> {
1704 if key.code == self.toggle_key && key.modifiers.is_empty() {
1706 let effect = self.toggle();
1707 return Some(effect.into_iter().collect());
1708 }
1709
1710 if !self.freeze.enabled {
1712 return None;
1713 }
1714
1715 if matches!(self.freeze.overlay, Some(DebugOverlay::ActionLog(_))) {
1720 let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay else {
1721 return Some(vec![]);
1722 };
1723
1724 if log.search_input_active {
1725 let is_text_input_char = !key.modifiers.contains(KeyModifiers::CONTROL)
1726 && !key.modifiers.contains(KeyModifiers::ALT);
1727 match key.code {
1728 KeyCode::Esc | KeyCode::Enter => {
1729 log.search_input_active = false;
1730 return Some(vec![]);
1731 }
1732 KeyCode::Backspace => {
1733 if !log.pop_search_char() {
1734 log.search_input_active = false;
1735 }
1736 return Some(vec![]);
1737 }
1738 KeyCode::Char(c) if is_text_input_char => {
1739 log.push_search_char(c);
1740 return Some(vec![]);
1741 }
1742 _ => {
1743 return Some(vec![]);
1744 }
1745 }
1746 }
1747
1748 if key.code == KeyCode::Backspace {
1750 self.handle_action(DebugAction::CloseOverlay);
1751 return Some(vec![]);
1752 }
1753
1754 if key.code == KeyCode::Char('/') {
1755 log.search_input_active = true;
1756 return Some(vec![]);
1757 }
1758
1759 let prev_match = key.code == KeyCode::Char('N');
1761 if prev_match {
1762 log.search_prev();
1763 return Some(vec![]);
1764 }
1765
1766 if key.code == KeyCode::Char('n') {
1767 log.search_next();
1768 return Some(vec![]);
1769 }
1770
1771 let action = match key.code {
1772 KeyCode::Char('j') | KeyCode::Down => Some(DebugAction::ActionLogScrollDown),
1773 KeyCode::Char('k') | KeyCode::Up => Some(DebugAction::ActionLogScrollUp),
1774 KeyCode::Char('g') => Some(DebugAction::ActionLogScrollTop),
1775 KeyCode::Char('G') => Some(DebugAction::ActionLogScrollBottom),
1776 KeyCode::PageDown => Some(DebugAction::ActionLogPageDown),
1777 KeyCode::PageUp => Some(DebugAction::ActionLogPageUp),
1778 KeyCode::Enter => Some(DebugAction::ActionLogShowDetail),
1779 _ => None,
1780 };
1781 if let Some(action) = action {
1782 self.handle_action(action);
1783 return Some(vec![]);
1784 }
1785 }
1786
1787 if matches!(
1789 self.freeze.overlay,
1790 Some(DebugOverlay::State(_)) | Some(DebugOverlay::Components(_))
1791 ) {
1792 let (search_active, is_state) =
1793 if matches!(self.freeze.overlay, Some(DebugOverlay::State(_))) {
1794 (self.state_search_active, true)
1795 } else {
1796 (self.components_search_active, false)
1797 };
1798
1799 if search_active {
1800 let is_text = !key.modifiers.contains(KeyModifiers::CONTROL)
1801 && !key.modifiers.contains(KeyModifiers::ALT);
1802 match key.code {
1803 KeyCode::Esc | KeyCode::Enter => {
1804 if is_state {
1805 self.state_search_active = false;
1806 } else {
1807 self.components_search_active = false;
1808 }
1809 return Some(vec![]);
1810 }
1811 KeyCode::Backspace => {
1812 let query = if is_state {
1813 &mut self.state_search_query
1814 } else {
1815 &mut self.components_search_query
1816 };
1817 if query.pop().is_none() {
1818 if is_state {
1819 self.state_search_active = false;
1820 } else {
1821 self.components_search_active = false;
1822 }
1823 }
1824 if is_state {
1825 self.refilter_state_tree();
1826 } else {
1827 self.refilter_components_tree();
1828 }
1829 return Some(vec![]);
1830 }
1831 KeyCode::Char(c) if is_text => {
1832 if is_state {
1833 self.state_search_query.push(c);
1834 self.refilter_state_tree();
1835 } else {
1836 self.components_search_query.push(c);
1837 self.refilter_components_tree();
1838 }
1839 return Some(vec![]);
1840 }
1841 _ => return Some(vec![]),
1842 }
1843 }
1844
1845 if key.code == KeyCode::Char('/') {
1846 if is_state {
1847 self.state_search_active = true;
1848 } else {
1849 self.components_search_active = true;
1850 }
1851 return Some(vec![]);
1852 }
1853 }
1854
1855 match key.code {
1856 KeyCode::Char('b') | KeyCode::Char('B') => {
1857 self.banner_position = self.banner_position.toggle();
1858 self.freeze.request_capture();
1859 return Some(vec![]);
1860 }
1861 KeyCode::Char('s') | KeyCode::Char('S') => {
1862 if matches!(self.freeze.overlay, Some(DebugOverlay::State(_))) {
1863 self.freeze.clear_overlay();
1864 } else if let Some(state) = state {
1865 let table = state.build_debug_table("Application State");
1866 self.set_state_overlay(table);
1867 } else if let Some(ref table) = self.state_snapshot {
1868 self.set_state_overlay(table.clone());
1869 } else {
1870 let table = DebugTableBuilder::new()
1871 .section("State")
1872 .entry(
1873 "hint",
1874 "Press 's' after providing state via render_with_state() or show_state_overlay()",
1875 )
1876 .finish("Application State");
1877 self.freeze.set_overlay(DebugOverlay::State(table));
1878 }
1879 return Some(vec![]);
1880 }
1881 KeyCode::Char('w') | KeyCode::Char('W') => {
1882 self.save_state_snapshot(state);
1883 return Some(vec![]);
1884 }
1885 _ => {}
1886 }
1887
1888 let action = match key.code {
1892 KeyCode::Char('a') | KeyCode::Char('A') => Some(DebugAction::ToggleActionLog),
1893 KeyCode::Char('c') | KeyCode::Char('C') => Some(DebugAction::ToggleComponents),
1894 KeyCode::Char('y') | KeyCode::Char('Y') => Some(DebugAction::CopyFrame),
1895 KeyCode::Char('i') | KeyCode::Char('I') => Some(DebugAction::ToggleMouseCapture),
1896 KeyCode::Char('q') | KeyCode::Char('Q') => Some(DebugAction::CloseOverlay),
1897 _ => None,
1898 };
1899
1900 if let Some(action) = action {
1901 let effect = self.handle_action(action);
1902 return Some(effect.into_iter().collect());
1903 }
1904
1905 match &self.freeze.overlay {
1907 Some(DebugOverlay::State(_)) => {
1908 if key.code == KeyCode::Backspace {
1909 self.handle_action(DebugAction::CloseOverlay);
1910 return Some(vec![]);
1911 }
1912 if key.code == KeyCode::Enter {
1914 if let Some(ref id) = self.state_tree_selected {
1915 if id.starts_with("entry:") {
1916 self.handle_action(DebugAction::StateTreeShowDetail);
1917 return Some(vec![]);
1918 }
1919 }
1920 }
1921 self.sync_state_tree_state();
1922 let filtered = &self.state_tree_filtered;
1923 let selected_id = self.state_tree_selected.as_ref();
1924 let expanded_ids = &self.state_tree_expanded;
1925 let style = self.state_tree_style();
1926 let palette = &self.style;
1927 let render_node = |ctx: TreeNodeRender<'_, String, String>| -> Line<'static> {
1928 render_state_tree_node(ctx, palette)
1929 };
1930 let props = Self::build_tree_props(
1931 filtered,
1932 selected_id,
1933 expanded_ids,
1934 style,
1935 &render_node,
1936 );
1937 let actions: Vec<_> = self
1938 .state_tree_view
1939 .handle_event(&EventKind::Key(key), props)
1940 .into_iter()
1941 .collect();
1942 if !actions.is_empty() {
1943 self.apply_state_tree_actions(actions);
1944 return Some(vec![]);
1945 }
1946 }
1947 Some(DebugOverlay::Inspect(table)) => {
1948 if key.code == KeyCode::Backspace {
1950 self.handle_action(DebugAction::CloseOverlay);
1951 return Some(vec![]);
1952 }
1953 if self
1954 .table_scroll
1955 .handle_scroll_key(key.code, table.rows.len())
1956 {
1957 return Some(vec![]);
1958 }
1959 }
1960 Some(DebugOverlay::ActionDetail(detail)) => {
1961 if matches!(key.code, KeyCode::Esc | KeyCode::Backspace | KeyCode::Enter) {
1963 self.handle_action(DebugAction::ActionLogBackToList);
1964 return Some(vec![]);
1965 }
1966
1967 let params_lines = self.detail_params_lines(detail);
1968 if self
1969 .detail_scroll
1970 .handle_scroll_key(key.code, params_lines.len())
1971 {
1972 return Some(vec![]);
1973 }
1974 }
1975 Some(DebugOverlay::Components(_)) => {
1976 if key.code == KeyCode::Backspace {
1977 self.handle_action(DebugAction::CloseOverlay);
1978 return Some(vec![]);
1979 }
1980 if key.code == KeyCode::Enter {
1982 if let Some(ref id) = self.components_tree_selected {
1983 if id.starts_with("comp:") {
1984 self.handle_action(DebugAction::ComponentShowDetail);
1985 return Some(vec![]);
1986 }
1987 }
1988 }
1989 let filtered = &self.components_tree_filtered;
1990 let selected_id = self.components_tree_selected.as_ref();
1991 let expanded_ids = &self.components_tree_expanded;
1992 let style = self.state_tree_style();
1993 let palette = &self.style;
1994 let render_node = |ctx: TreeNodeRender<'_, String, String>| -> Line<'static> {
1995 render_state_tree_node(ctx, palette)
1996 };
1997 let props = Self::build_tree_props(
1998 filtered,
1999 selected_id,
2000 expanded_ids,
2001 style,
2002 &render_node,
2003 );
2004 let actions: Vec<_> = self
2005 .components_tree_view
2006 .handle_event(&EventKind::Key(key), props)
2007 .into_iter()
2008 .collect();
2009 if !actions.is_empty() {
2010 self.apply_components_tree_actions(actions);
2011 return Some(vec![]);
2012 }
2013 }
2014 Some(DebugOverlay::StateDetail(detail)) => {
2015 if matches!(key.code, KeyCode::Esc | KeyCode::Backspace) {
2016 self.handle_action(DebugAction::StateTreeBackToTree);
2017 return Some(vec![]);
2018 }
2019 let line_count = self.state_detail_lines(detail).len();
2020 if self.detail_scroll.handle_scroll_key(key.code, line_count) {
2021 return Some(vec![]);
2022 }
2023 }
2024 Some(DebugOverlay::ComponentDetail(detail)) => {
2025 if matches!(key.code, KeyCode::Esc | KeyCode::Backspace) {
2026 self.handle_action(DebugAction::ComponentBackToList);
2027 return Some(vec![]);
2028 }
2029 let line_count = self.component_detail_lines(detail).len();
2030 if self.detail_scroll.handle_scroll_key(key.code, line_count) {
2031 return Some(vec![]);
2032 }
2033 }
2034 _ => {}
2035 }
2036
2037 if key.code == KeyCode::Esc {
2039 let effect = self.toggle();
2040 return Some(effect.into_iter().collect());
2041 }
2042
2043 Some(vec![])
2045 }
2046
2047 fn toggle(&mut self) -> Option<DebugSideEffect<A>> {
2048 if self.freeze.enabled {
2049 #[cfg(feature = "subscriptions")]
2051 if let Some(ref handle) = self.sub_handle {
2052 handle.resume();
2053 }
2054
2055 #[cfg(feature = "tasks")]
2056 let task_queued = if let Some(ref handle) = self.task_handle {
2057 handle.resume()
2058 } else {
2059 vec![]
2060 };
2061 #[cfg(not(feature = "tasks"))]
2062 let task_queued: Vec<A> = vec![];
2063
2064 let queued = self.freeze.take_queued();
2065 self.freeze.disable();
2066 self.state_snapshot = None;
2067 self.state_tree_nodes.clear();
2068 self.state_tree_filtered.clear();
2069 self.state_tree_selected = None;
2070 self.state_tree_expanded.clear();
2071 self.state_tree_view = TreeView::new();
2072 self.table_scroll.reset();
2073 self.detail_scroll.reset();
2074
2075 let mut all_queued = queued;
2077 all_queued.extend(task_queued);
2078
2079 if all_queued.is_empty() {
2080 None
2081 } else {
2082 Some(DebugSideEffect::ProcessQueuedActions(all_queued))
2083 }
2084 } else {
2085 #[cfg(feature = "tasks")]
2087 if let Some(ref handle) = self.task_handle {
2088 handle.pause();
2089 }
2090 #[cfg(feature = "subscriptions")]
2091 if let Some(ref handle) = self.sub_handle {
2092 handle.pause();
2093 }
2094 self.freeze.enable();
2095 self.state_snapshot = None;
2096 self.state_tree_nodes.clear();
2097 self.state_tree_filtered.clear();
2098 self.state_tree_selected = None;
2099 self.state_tree_expanded.clear();
2100 self.state_tree_view = TreeView::new();
2101 self.table_scroll.reset();
2102 self.detail_scroll.reset();
2103 None
2104 }
2105 }
2106
2107 fn handle_action(&mut self, action: DebugAction) -> Option<DebugSideEffect<A>> {
2108 match action {
2109 DebugAction::Toggle => self.toggle(),
2110 DebugAction::CopyFrame => {
2111 let text = &self.freeze.snapshot_text;
2112 let encoded = BASE64_STANDARD.encode(text);
2114 print!("\x1b]52;c;{}\x07", encoded);
2115 std::io::stdout().flush().ok();
2116 self.freeze.set_message("Copied to clipboard");
2117 None
2118 }
2119 DebugAction::ToggleState => {
2120 if matches!(self.freeze.overlay, Some(DebugOverlay::State(_))) {
2121 self.freeze.clear_overlay();
2122 } else if let Some(ref table) = self.state_snapshot {
2123 self.set_state_overlay(table.clone());
2124 } else {
2125 let table = DebugTableBuilder::new()
2127 .section("State")
2128 .entry(
2129 "hint",
2130 "Press 's' after providing state via render_with_state() or show_state_overlay()",
2131 )
2132 .finish("Application State");
2133 self.freeze.set_overlay(DebugOverlay::State(table));
2134 }
2135 None
2136 }
2137 DebugAction::ToggleActionLog => {
2138 if matches!(self.freeze.overlay, Some(DebugOverlay::ActionLog(_))) {
2139 self.freeze.clear_overlay();
2140 } else {
2141 self.show_action_log();
2142 }
2143 None
2144 }
2145 DebugAction::ToggleComponents => {
2146 if matches!(self.freeze.overlay, Some(DebugOverlay::Components(_))) {
2147 self.freeze.clear_overlay();
2148 } else {
2149 self.show_components();
2150 }
2151 None
2152 }
2153 DebugAction::ActionLogScrollUp => {
2154 if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
2155 log.scroll_up();
2156 }
2157 None
2158 }
2159 DebugAction::ActionLogScrollDown => {
2160 if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
2161 log.scroll_down();
2162 }
2163 None
2164 }
2165 DebugAction::ActionLogScrollTop => {
2166 if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
2167 log.scroll_to_top();
2168 }
2169 None
2170 }
2171 DebugAction::ActionLogScrollBottom => {
2172 if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
2173 log.scroll_to_bottom();
2174 }
2175 None
2176 }
2177 DebugAction::ActionLogPageUp => {
2178 if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
2179 log.page_up(10);
2180 }
2181 None
2182 }
2183 DebugAction::ActionLogPageDown => {
2184 if let Some(DebugOverlay::ActionLog(ref mut log)) = self.freeze.overlay {
2185 log.page_down(10);
2186 }
2187 None
2188 }
2189 DebugAction::ActionLogShowDetail => {
2190 if let Some(DebugOverlay::ActionLog(ref log)) = self.freeze.overlay {
2191 if let Some(detail) = log.selected_detail() {
2192 self.detail_scroll.reset();
2193 self.freeze.set_overlay(DebugOverlay::ActionDetail(detail));
2194 }
2195 }
2196 None
2197 }
2198 DebugAction::ActionLogBackToList => {
2199 if matches!(self.freeze.overlay, Some(DebugOverlay::ActionDetail(_))) {
2201 self.show_action_log();
2202 }
2203 None
2204 }
2205 DebugAction::StateTreeShowDetail => {
2206 if let Some(ref selected_id) = self.state_tree_selected.clone() {
2207 if let Some(detail) = self.build_state_entry_detail(selected_id) {
2208 self.detail_scroll.reset();
2209 self.freeze.set_overlay(DebugOverlay::StateDetail(detail));
2210 }
2211 }
2212 None
2213 }
2214 DebugAction::StateTreeBackToTree => {
2215 if matches!(self.freeze.overlay, Some(DebugOverlay::StateDetail(_))) {
2216 if let Some(ref table) = self.state_snapshot {
2217 self.set_state_overlay(table.clone());
2218 }
2219 }
2220 None
2221 }
2222 DebugAction::ComponentShowDetail => {
2223 if let Some(DebugOverlay::Components(ref overlay)) = self.freeze.overlay {
2224 if let Some(idx) = self
2226 .components_tree_selected
2227 .as_ref()
2228 .and_then(|id| id.strip_prefix("comp:"))
2229 .and_then(|n| n.parse::<usize>().ok())
2230 {
2231 if let Some(comp) = overlay.components.get(idx) {
2232 let detail =
2233 super::table::ComponentDetailOverlay::from_snapshot(comp, idx);
2234 self.detail_scroll.reset();
2235 self.freeze
2236 .set_overlay(DebugOverlay::ComponentDetail(detail));
2237 }
2238 }
2239 }
2240 None
2241 }
2242 DebugAction::ComponentBackToList => {
2243 if let Some(DebugOverlay::ComponentDetail(ref detail)) = self.freeze.overlay {
2244 let selected_id = format!("comp:{}", detail.index);
2245 self.show_components();
2246 self.components_tree_selected = Some(selected_id);
2247 }
2248 None
2249 }
2250 DebugAction::ToggleMouseCapture => {
2251 self.freeze.toggle_mouse_capture();
2252 None
2253 }
2254 DebugAction::InspectCell { column, row } => {
2255 if let Some(ref snapshot) = self.freeze.snapshot {
2256 let overlay = self.build_inspect_overlay(column, row, snapshot);
2257 self.table_scroll.reset();
2258 self.freeze.set_overlay(DebugOverlay::Inspect(overlay));
2259 }
2260 self.freeze.mouse_capture_enabled = false;
2261 None
2262 }
2263 DebugAction::CloseOverlay => {
2264 self.freeze.clear_overlay();
2265 None
2266 }
2267 DebugAction::RequestCapture => {
2268 self.freeze.request_capture();
2269 None
2270 }
2271 }
2272 }
2273
2274 fn split_for_banner(&self, area: Rect) -> (Rect, Rect) {
2275 let banner_height = area.height.min(1);
2276 match self.banner_position {
2277 BannerPosition::Bottom => {
2278 let app_area = Rect {
2279 height: area.height.saturating_sub(banner_height),
2280 ..area
2281 };
2282 let banner_area = Rect {
2283 y: area.y.saturating_add(app_area.height),
2284 height: banner_height,
2285 ..area
2286 };
2287 (app_area, banner_area)
2288 }
2289 BannerPosition::Top => {
2290 let banner_area = Rect {
2291 y: area.y,
2292 height: banner_height,
2293 ..area
2294 };
2295 let app_area = Rect {
2296 y: area.y.saturating_add(banner_height),
2297 height: area.height.saturating_sub(banner_height),
2298 ..area
2299 };
2300 (app_area, banner_area)
2301 }
2302 }
2303 }
2304
2305 fn render_debug_overlay(&mut self, frame: &mut Frame, app_area: Rect, banner_area: Rect) {
2306 let overlay = self.freeze.overlay.clone();
2307
2308 if let Some(ref overlay) = overlay {
2310 dim_buffer(frame.buffer_mut(), self.style.dim_factor);
2311
2312 match overlay {
2313 DebugOverlay::Inspect(table) => {
2314 self.render_table_modal(frame, app_area, table);
2315 }
2316 DebugOverlay::State(table) => {
2317 self.render_state_tree_modal(frame, app_area, table);
2318 }
2319 DebugOverlay::ActionLog(log) => {
2320 self.render_action_log_modal(frame, app_area, log);
2321 }
2322 DebugOverlay::ActionDetail(detail) => {
2323 self.render_action_detail_modal(frame, app_area, detail);
2324 }
2325 DebugOverlay::Components(components) => {
2326 if let Some(idx) = self
2330 .components_tree_selected
2331 .as_ref()
2332 .and_then(|id| id.strip_prefix("comp:"))
2333 .and_then(|n| n.parse::<usize>().ok())
2334 {
2335 if let Some(comp) = components.components.get(idx) {
2336 if let Some(area) = comp.last_area {
2337 highlight_rect(frame.buffer_mut(), area, app_area);
2338 }
2339 }
2340 }
2341 self.render_components_modal(frame, app_area, &components.clone());
2342 }
2343 DebugOverlay::StateDetail(detail) => {
2344 self.render_state_detail_modal(frame, app_area, &detail.clone());
2345 }
2346 DebugOverlay::ComponentDetail(detail) => {
2347 self.render_component_detail_modal(frame, app_area, &detail.clone());
2348 }
2349 }
2350 }
2351
2352 self.render_banner(frame, banner_area);
2354 }
2355
2356 fn render_banner(&self, frame: &mut Frame, banner_area: Rect) {
2357 if banner_area.height == 0 {
2358 return;
2359 }
2360
2361 use ratatui::text::Span;
2362
2363 let keys = &self.style.key_styles;
2364 let toggle_key_str = format_key(self.toggle_key);
2365 let label_style = self.style.label_style;
2366 let value_style = self.style.value_style;
2367
2368 let mut left_items: Vec<StatusBarItem<'static>> = Vec::new();
2369 left_items.push(StatusBarItem::span(Span::styled(
2370 " DEBUG ",
2371 self.style.title_style,
2372 )));
2373 left_items.push(StatusBarItem::text(" "));
2374
2375 let push_item =
2376 |items: &mut Vec<StatusBarItem<'static>>, key: &str, label: &str, key_style: Style| {
2377 items.push(StatusBarItem::span(Span::styled(
2378 format!(" {key} "),
2379 key_style,
2380 )));
2381 items.push(StatusBarItem::span(Span::styled(
2382 format!(" {label} "),
2383 label_style,
2384 )));
2385 };
2386
2387 push_item(&mut left_items, &toggle_key_str, "resume", keys.toggle);
2388 push_item(&mut left_items, "a", "actions", keys.actions);
2389 push_item(&mut left_items, "c", "components", keys.components);
2390 push_item(&mut left_items, "s", "state", keys.state);
2391 push_item(
2392 &mut left_items,
2393 "b",
2394 self.banner_position.label(),
2395 keys.actions,
2396 );
2397 push_item(&mut left_items, "y", "copy", keys.copy);
2398
2399 if self.freeze.mouse_capture_enabled {
2400 push_item(&mut left_items, "click", "inspect", keys.mouse);
2401 } else {
2402 push_item(&mut left_items, "i", "mouse", keys.mouse);
2403 }
2404
2405 let mut right_items: Vec<StatusBarItem<'static>> = Vec::new();
2406 if let Some(ref msg) = self.freeze.message {
2407 right_items.push(StatusBarItem::span(Span::styled(
2408 format!(" {msg} "),
2409 value_style,
2410 )));
2411 }
2412
2413 let style = StatusBarStyle {
2414 base: BaseStyle {
2415 border: None,
2416 padding: Padding::default(),
2417 bg: self.style.banner_bg.bg,
2418 fg: None,
2419 },
2420 text: label_style,
2421 hint_key: keys.toggle,
2422 hint_label: label_style,
2423 separator: label_style,
2424 };
2425
2426 let left = StatusBarSection::items(&left_items).with_separator("");
2427 let right = if right_items.is_empty() {
2428 StatusBarSection::empty()
2429 } else {
2430 StatusBarSection::items(&right_items).with_separator("")
2431 };
2432
2433 let mut status_bar = StatusBar::new();
2434 <StatusBar as tui_dispatch_core::Component<()>>::render(
2435 &mut status_bar,
2436 frame,
2437 banner_area,
2438 StatusBarProps {
2439 left,
2440 center: StatusBarSection::empty(),
2441 right,
2442 style,
2443 is_focused: false,
2444 },
2445 );
2446 }
2447
2448 fn render_state_tree_modal(
2449 &mut self,
2450 frame: &mut Frame,
2451 app_area: Rect,
2452 table: &DebugTableOverlay,
2453 ) {
2454 self.sync_state_tree_state();
2455
2456 let mut tree_view = std::mem::take(&mut self.state_tree_view);
2457 let filtered = &self.state_tree_filtered;
2458 let selected_id = self.state_tree_selected.as_ref();
2459 let expanded_ids = &self.state_tree_expanded;
2460 let style = self.state_tree_style();
2461 let palette = &self.style;
2462 let render_node = |ctx: TreeNodeRender<'_, String, String>| -> Line<'static> {
2463 render_state_tree_node(ctx, palette)
2464 };
2465 let search_footer =
2466 self.search_footer_center_items(&self.state_search_query, self.state_search_active);
2467 let hints = if self.state_search_active {
2468 SEARCH_INPUT_HINTS
2469 } else {
2470 STATE_TREE_HINTS
2471 };
2472
2473 self.render_overlay_container(
2474 frame,
2475 app_area,
2476 &table.title,
2477 Some(hints),
2478 search_footer,
2479 |frame, body_area| {
2480 let body_area = Rect {
2481 x: body_area.x.saturating_add(1),
2482 y: body_area.y,
2483 width: body_area.width.saturating_sub(2),
2484 height: body_area.height,
2485 };
2486 if body_area.width == 0 {
2487 return;
2488 }
2489
2490 let props = Self::build_tree_props(
2491 filtered,
2492 selected_id,
2493 expanded_ids,
2494 style.clone(),
2495 &render_node,
2496 );
2497
2498 <TreeView<String> as tui_dispatch_core::Component<StateTreeAction>>::render(
2499 &mut tree_view,
2500 frame,
2501 body_area,
2502 props,
2503 );
2504 },
2505 );
2506
2507 self.state_tree_view = tree_view;
2508 }
2509
2510 fn render_table_modal(&mut self, frame: &mut Frame, app_area: Rect, table: &DebugTableOverlay) {
2511 let scrollbar_style = self.component_scrollbar_style();
2512 let table_scroll_offset = self.table_scroll.offset;
2513 let style_snapshot = self.style.clone();
2514 let mut rows_page_size: usize = 0;
2515
2516 self.render_overlay_container(
2517 frame,
2518 app_area,
2519 &table.title,
2520 Some(INSPECT_HINTS),
2521 None,
2522 |frame, content_area| {
2523 let mut table_area = content_area;
2524 if let Some(ref preview) = table.cell_preview {
2525 if table_area.height > 1 {
2526 let preview_area = Rect {
2527 height: 1,
2528 ..table_area
2529 };
2530 let preview_widget =
2531 CellPreviewWidget::from_style(preview, &style_snapshot);
2532 frame.render_widget(preview_widget, preview_area);
2533
2534 table_area = Rect {
2535 y: table_area.y.saturating_add(1),
2536 height: table_area.height.saturating_sub(1),
2537 ..table_area
2538 };
2539 }
2540 }
2541
2542 if table_area.height == 0 || table_area.width == 0 {
2543 return;
2544 }
2545
2546 use ratatui::text::{Line, Span};
2547 use ratatui::widgets::Paragraph;
2548
2549 let header_area = Rect {
2550 height: 1,
2551 ..table_area
2552 };
2553 let rows_area = Rect {
2554 y: table_area.y.saturating_add(1),
2555 height: table_area.height.saturating_sub(1),
2556 ..table_area
2557 };
2558 rows_page_size = rows_area.height as usize;
2559
2560 let style = DebugTableStyle::from_style(&style_snapshot);
2561 let show_scrollbar = rows_area.height > 0
2562 && table.rows.len() > rows_area.height as usize
2563 && table_area.width > 1;
2564 let text_width = if show_scrollbar {
2565 table_area.width.saturating_sub(1)
2566 } else {
2567 table_area.width
2568 } as usize;
2569
2570 if text_width == 0 {
2571 return;
2572 }
2573
2574 let max_key_len = table
2575 .rows
2576 .iter()
2577 .filter_map(|row| match row {
2578 super::table::DebugTableRow::Entry { key, .. } => Some(key.chars().count()),
2579 super::table::DebugTableRow::Section(_) => None,
2580 })
2581 .max()
2582 .unwrap_or(0);
2583 let max_label = text_width.saturating_sub(8).max(10);
2584 let label_width = (max_key_len + 2).clamp(12, 30).min(max_label);
2585 let value_width = text_width.saturating_sub(label_width + 2).max(1);
2586
2587 let header_line = Line::from(vec![
2588 Span::styled(pad_text("Field", label_width), style.header),
2589 Span::styled(" ", style.header),
2590 Span::styled(pad_text("Value", value_width), style.header),
2591 ]);
2592 frame.render_widget(Paragraph::new(header_line), header_area);
2593
2594 if rows_area.height == 0 {
2595 return;
2596 }
2597
2598 let syntax_style = DebugSyntaxStyle::from_style(&style_snapshot, style.value);
2599 let mut rows = Vec::new();
2600 let mut entry_index = 0usize;
2601 for row in table.rows.iter() {
2602 match row {
2603 super::table::DebugTableRow::Section(title) => {
2604 entry_index = 0;
2605 let mut text = format!(" {title} ");
2606 text = truncate_with_ellipsis(&text, text_width);
2607 text = pad_text(&text, text_width);
2608 rows.push(Line::from(vec![Span::styled(text, style.section)]));
2609 }
2610 super::table::DebugTableRow::Entry { key, value } => {
2611 let row_style = if entry_index % 2 == 0 {
2612 style.row_styles.0
2613 } else {
2614 style.row_styles.1
2615 };
2616 entry_index = entry_index.saturating_add(1);
2617 let key_text = pad_text(key, label_width);
2618 let mut value_text = truncate_with_ellipsis(value, value_width);
2619 value_text = pad_text(&value_text, value_width);
2620
2621 let mut value_spans: Vec<Span<'static>> =
2622 debug_spans(&value_text, &syntax_style)
2623 .into_iter()
2624 .map(|span| span.patch_style(row_style))
2625 .collect();
2626
2627 let mut spans = vec![
2628 Span::styled(key_text, style.key).patch_style(row_style),
2629 Span::styled(" ", row_style),
2630 ];
2631 spans.append(&mut value_spans);
2632
2633 rows.push(Line::from(spans));
2634 }
2635 }
2636 }
2637
2638 render_scroll_view(
2639 frame,
2640 rows_area,
2641 &rows,
2642 table_scroll_offset,
2643 None,
2644 &scrollbar_style,
2645 );
2646 },
2647 );
2648
2649 self.table_scroll.page_size = rows_page_size.max(1);
2650 }
2651
2652 fn render_action_log_modal(&self, frame: &mut Frame, app_area: Rect, log: &ActionLogOverlay) {
2653 let entry_count = log.entries.len();
2654 let filter_active = log.has_search_query();
2655 let filtered_count = if filter_active {
2656 log.search_match_count()
2657 } else {
2658 entry_count
2659 };
2660 let title = if log.has_search_query() {
2661 format!("{} ({} / {} shown)", log.title, filtered_count, entry_count)
2662 } else if entry_count > 0 {
2663 format!("{} ({} entries)", log.title, entry_count)
2664 } else {
2665 format!("{} (empty)", log.title)
2666 };
2667
2668 let action_log_hints = if log.search_input_active {
2669 ACTION_LOG_SEARCH_INPUT_HINTS
2670 } else {
2671 ACTION_LOG_HINTS
2672 };
2673
2674 let log_style = ActionLogStyle::from_style(&self.style);
2675 let log_syntax = DebugSyntaxStyle::from_style(&self.style, log_style.params);
2676 let text_secondary_fg = self.style.text_secondary;
2677
2678 self.render_overlay_container(
2679 frame,
2680 app_area,
2681 &title,
2682 Some(action_log_hints),
2683 self.search_footer_center_items(&log.search_query, log.search_input_active),
2684 |frame, log_area| {
2685 use ratatui::text::{Line, Span};
2686 use ratatui::widgets::Paragraph;
2687
2688 let style = &log_style;
2689 let spacing = 1usize;
2690 let seq_width = 5usize;
2691 let name_width = 20usize;
2692 let elapsed_width = 8usize;
2693
2694 let header_area = Rect {
2695 height: 1,
2696 ..log_area
2697 };
2698 let body_area = Rect {
2699 y: log_area.y.saturating_add(1),
2700 height: log_area.height.saturating_sub(1),
2701 ..log_area
2702 };
2703
2704 let selected_visible_idx = if filtered_count == 0 {
2705 0
2706 } else if filter_active {
2707 let selected_match_position = log
2708 .search_matches
2709 .iter()
2710 .position(|&idx| idx == log.selected);
2711 selected_match_position
2713 .unwrap_or(log.search_match_index.min(filtered_count.saturating_sub(1)))
2714 } else {
2715 log.selected.min(filtered_count.saturating_sub(1))
2716 };
2717
2718 let show_scrollbar = body_area.height > 0
2719 && filtered_count > body_area.height as usize
2720 && log_area.width > 1;
2721 let text_width = if show_scrollbar {
2722 log_area.width.saturating_sub(1)
2723 } else {
2724 log_area.width
2725 } as usize;
2726 let params_width = text_width
2727 .saturating_sub(seq_width + name_width + elapsed_width + spacing * 3)
2728 .max(1);
2729
2730 let header_line = Line::from(vec![
2731 Span::styled(pad_text("#", seq_width), style.header),
2732 Span::styled(" ", style.header),
2733 Span::styled(pad_text("Action", name_width), style.header),
2734 Span::styled(" ", style.header),
2735 Span::styled(pad_text("Params", params_width), style.header),
2736 Span::styled(" ", style.header),
2737 Span::styled(pad_text("Elapsed", elapsed_width), style.header),
2738 ]);
2739 frame.render_widget(Paragraph::new(header_line), header_area);
2740
2741 if body_area.height == 0 {
2742 return;
2743 }
2744
2745 let syntax_style = log_syntax.clone();
2746 let rows: Vec<Line> = if filtered_count == 0 {
2747 vec![Line::from(vec![Span::styled(
2748 " (no matching actions) ",
2749 Style::default().fg(text_secondary_fg),
2750 )])]
2751 } else {
2752 (0..filtered_count)
2753 .map(|visible_idx| {
2754 let entry_idx = if filter_active {
2755 log.search_matches[visible_idx]
2756 } else {
2757 visible_idx
2758 };
2759 let entry = &log.entries[entry_idx];
2760 let is_selected = visible_idx == selected_visible_idx;
2761 let row_style = if is_selected {
2762 style.selected
2763 } else if visible_idx % 2 == 0 {
2764 style.row_styles.0
2765 } else {
2766 style.row_styles.1
2767 };
2768
2769 let seq_text = pad_text(&entry.sequence.to_string(), seq_width);
2770 let name_text = pad_text(&entry.name, name_width);
2771 let elapsed_text = pad_text(&entry.elapsed, elapsed_width);
2772
2773 let params_compact = entry.params.replace('\n', " ");
2774 let params_compact = params_compact
2775 .split_whitespace()
2776 .collect::<Vec<_>>()
2777 .join(" ");
2778 let params_trimmed =
2779 truncate_with_ellipsis(¶ms_compact, params_width);
2780 let params_text = pad_text(¶ms_trimmed, params_width);
2781 let mut params_spans: Vec<Span<'static>> =
2782 debug_spans(¶ms_text, &syntax_style)
2783 .into_iter()
2784 .map(|span| span.patch_style(row_style))
2785 .collect();
2786
2787 let mut spans = vec![
2788 Span::styled(seq_text, style.sequence).patch_style(row_style),
2789 Span::styled(" ", row_style),
2790 Span::styled(name_text, style.name).patch_style(row_style),
2791 Span::styled(" ", row_style),
2792 ];
2793 spans.append(&mut params_spans);
2794 spans.push(Span::styled(" ", row_style));
2795 spans.push(
2796 Span::styled(elapsed_text, style.elapsed).patch_style(row_style),
2797 );
2798
2799 Line::from(spans)
2800 })
2801 .collect()
2802 };
2803
2804 let visible_rows = body_area.height as usize;
2805 let scroll_offset = if visible_rows == 0 || selected_visible_idx < visible_rows {
2806 0
2807 } else {
2808 selected_visible_idx - visible_rows + 1
2809 };
2810 let scrollbar = self.component_scrollbar_style();
2811 render_scroll_view(frame, body_area, &rows, scroll_offset, None, &scrollbar);
2812 },
2813 );
2814 }
2815
2816 fn search_footer_center_items(
2817 &self,
2818 query: &str,
2819 input_active: bool,
2820 ) -> Option<Vec<StatusBarItem<'static>>> {
2821 use ratatui::text::Span;
2822
2823 if query.is_empty() && !input_active {
2824 return None;
2825 }
2826
2827 let key_style = Style::default()
2828 .fg(self.style.bg_deep)
2829 .bg(self.style.text_secondary)
2830 .add_modifier(Modifier::BOLD);
2831 let value_style = Style::default().fg(self.style.text_primary);
2832
2833 let filter_value = if input_active {
2834 format!("/{}_", query)
2835 } else {
2836 format!("/{}", query)
2837 };
2838
2839 Some(vec![
2840 StatusBarItem::span(Span::styled(" filter ", key_style)),
2841 StatusBarItem::span(Span::styled(format!(" {} ", filter_value), value_style)),
2842 ])
2843 }
2844
2845 fn render_action_detail_modal(
2846 &mut self,
2847 frame: &mut Frame,
2848 app_area: Rect,
2849 detail: &super::table::ActionDetailOverlay,
2850 ) {
2851 let title = format!("Action #{} - {}", detail.sequence, detail.name);
2852 let param_lines = self.detail_params_lines(detail);
2853 let scrollbar_style = self.component_scrollbar_style();
2854 let detail_scroll_offset = self.detail_scroll.offset;
2855 let detail_page_size = self.detail_scroll.page_size;
2856 let detail_label_fg = self.style.text_secondary;
2857 let detail_value_fg = self.style.text_primary;
2858 let detail_params_bg = self.style.overlay_bg_dark;
2859 let mut body_page_size: usize = 0;
2860
2861 self.render_overlay_container(
2862 frame,
2863 app_area,
2864 &title,
2865 Some(ACTION_DETAIL_HINTS),
2866 None,
2867 |frame, detail_area| {
2868 use ratatui::text::{Line, Span};
2869 use ratatui::widgets::Paragraph;
2870
2871 let label_style = Style::default().fg(detail_label_fg);
2872 let value_style = Style::default().fg(detail_value_fg);
2873
2874 let header_lines = vec![
2875 Line::from(vec![
2876 Span::styled("Name: ", label_style),
2877 Span::styled(&detail.name, value_style),
2878 ]),
2879 Line::from(vec![
2880 Span::styled("Sequence: ", label_style),
2881 Span::styled(detail.sequence.to_string(), value_style),
2882 ]),
2883 Line::from(vec![
2884 Span::styled("Elapsed: ", label_style),
2885 Span::styled(&detail.elapsed, value_style),
2886 ]),
2887 Line::from(""),
2888 Line::from(Span::styled("Parameters:", label_style)),
2889 ];
2890
2891 let mut param_lines = param_lines.clone();
2892 if param_lines.is_empty() {
2893 param_lines.push(Line::from(Span::styled(" (none)", value_style)));
2894 }
2895 let param_lines_len = param_lines.len();
2896
2897 let footer_lines = vec![Line::from("")];
2898
2899 let header_height = header_lines.len() as u16;
2900 let footer_height = footer_lines.len() as u16;
2901
2902 let header_area_height = header_height.min(detail_area.height);
2903 let header_area = Rect {
2904 height: header_area_height,
2905 ..detail_area
2906 };
2907 if header_area.height > 0 {
2908 let paragraph = Paragraph::new(header_lines);
2909 frame.render_widget(paragraph, header_area);
2910 }
2911
2912 let footer_area_height =
2913 footer_height.min(detail_area.height.saturating_sub(header_area_height));
2914 let footer_area = Rect {
2915 x: detail_area.x,
2916 y: detail_area
2917 .y
2918 .saturating_add(detail_area.height.saturating_sub(footer_area_height)),
2919 width: detail_area.width,
2920 height: footer_area_height,
2921 };
2922 if footer_area.height > 0 {
2923 let paragraph = Paragraph::new(footer_lines);
2924 frame.render_widget(paragraph, footer_area);
2925 }
2926
2927 let params_area = Rect {
2928 x: detail_area.x,
2929 y: detail_area.y.saturating_add(header_area_height),
2930 width: detail_area.width,
2931 height: detail_area
2932 .height
2933 .saturating_sub(header_area_height + footer_area_height),
2934 };
2935 body_page_size = params_area.height as usize;
2936
2937 let max_offset = param_lines_len.saturating_sub(detail_page_size.max(1));
2938 let current_scroll_offset = detail_scroll_offset.min(max_offset);
2939
2940 if params_area.height > 0 {
2941 render_scroll_view(
2942 frame,
2943 params_area,
2944 ¶m_lines,
2945 current_scroll_offset,
2946 Some(detail_params_bg),
2947 &scrollbar_style,
2948 );
2949 }
2950 },
2951 );
2952
2953 self.detail_scroll.page_size = body_page_size.max(1);
2954 }
2955
2956 fn sync_components_tree_state(&mut self) {
2957 let nodes = &self.components_tree_nodes;
2958 self.components_tree_expanded
2959 .retain(|id| Self::tree_contains_id(nodes, id));
2960
2961 let filtered = &self.components_tree_filtered;
2962 let selected_valid = self
2963 .components_tree_selected
2964 .as_deref()
2965 .map(|id| Self::tree_contains_id(filtered, id))
2966 .unwrap_or(false);
2967
2968 if !selected_valid {
2969 self.components_tree_selected = filtered.first().map(|node| node.id.clone());
2970 }
2971 }
2972
2973 fn render_components_modal(
2974 &mut self,
2975 frame: &mut Frame,
2976 app_area: Rect,
2977 overlay: &super::table::ComponentsOverlay,
2978 ) {
2979 let count = overlay.components.len();
2980 let title = if count > 0 {
2981 format!("{} ({} mounted)", overlay.title, count)
2982 } else {
2983 format!("{} (none)", overlay.title)
2984 };
2985
2986 self.sync_components_tree_state();
2987
2988 let mut tree_view = std::mem::take(&mut self.components_tree_view);
2989 let filtered = &self.components_tree_filtered;
2990 let selected_id = self.components_tree_selected.as_ref();
2991 let expanded_ids = &self.components_tree_expanded;
2992 let style = self.state_tree_style();
2993 let palette = &self.style;
2994 let render_node = |ctx: TreeNodeRender<'_, String, String>| -> Line<'static> {
2995 render_state_tree_node(ctx, palette)
2996 };
2997 let search_footer = self.search_footer_center_items(
2998 &self.components_search_query,
2999 self.components_search_active,
3000 );
3001 let hints = if self.components_search_active {
3002 SEARCH_INPUT_HINTS
3003 } else {
3004 COMPONENTS_HINTS
3005 };
3006
3007 self.render_overlay_container(
3008 frame,
3009 app_area,
3010 &title,
3011 Some(hints),
3012 search_footer,
3013 |frame, body_area| {
3014 let body_area = Rect {
3015 x: body_area.x.saturating_add(1),
3016 y: body_area.y,
3017 width: body_area.width.saturating_sub(2),
3018 height: body_area.height,
3019 };
3020 if body_area.width == 0 {
3021 return;
3022 }
3023
3024 let props = Self::build_tree_props(
3025 filtered,
3026 selected_id,
3027 expanded_ids,
3028 style.clone(),
3029 &render_node,
3030 );
3031
3032 <TreeView<String> as tui_dispatch_core::Component<StateTreeAction>>::render(
3033 &mut tree_view,
3034 frame,
3035 body_area,
3036 props,
3037 );
3038 },
3039 );
3040
3041 self.components_tree_view = tree_view;
3042 }
3043
3044 fn detail_params_lines(
3045 &self,
3046 detail: &super::table::ActionDetailOverlay,
3047 ) -> Vec<ratatui::text::Line<'static>> {
3048 use ratatui::text::{Line, Span};
3049
3050 if detail.params.is_empty() {
3051 return Vec::new();
3052 }
3053
3054 let value_style = Style::default().fg(self.style.text_primary);
3055 let syntax_style = DebugSyntaxStyle::from_style(&self.style, value_style);
3056
3057 detail
3058 .params
3059 .lines()
3060 .map(|line| {
3061 let mut spans = vec![Span::styled(" ", value_style)];
3062 spans.extend(debug_spans(line, &syntax_style));
3063 Line::from(spans)
3064 })
3065 .collect()
3066 }
3067
3068 fn state_detail_lines(
3069 &self,
3070 detail: &super::table::StateEntryDetail,
3071 ) -> Vec<ratatui::text::Line<'static>> {
3072 use ratatui::text::{Line, Span};
3073
3074 let label_style = Style::default().fg(self.style.text_secondary);
3075 let value_style = Style::default().fg(self.style.text_primary);
3076 let syntax_style = DebugSyntaxStyle::from_style(&self.style, value_style);
3077
3078 let mut lines = vec![
3079 Line::from(vec![
3080 Span::styled("Section: ", label_style),
3081 Span::styled(detail.section.clone(), value_style),
3082 ]),
3083 Line::from(vec![
3084 Span::styled("Key: ", label_style),
3085 Span::styled(detail.key.clone(), value_style),
3086 ]),
3087 Line::default(),
3088 Line::from(Span::styled("Value:", label_style)),
3089 ];
3090
3091 let pretty_value = super::format::pretty_reformat(&detail.value);
3092 for line in pretty_value.lines() {
3093 let mut spans = vec![Span::styled(" ", value_style)];
3094 spans.extend(debug_spans(line, &syntax_style));
3095 lines.push(Line::from(spans));
3096 }
3097
3098 if detail.value.is_empty() {
3099 lines.push(Line::from(Span::styled(" (empty)", label_style)));
3100 }
3101
3102 lines
3103 }
3104
3105 fn render_state_detail_modal(
3106 &mut self,
3107 frame: &mut Frame,
3108 app_area: Rect,
3109 detail: &super::table::StateEntryDetail,
3110 ) {
3111 let title = format!("{} - {}", detail.section, detail.key);
3112 let content_lines = self.state_detail_lines(detail);
3113 let scrollbar_style = self.component_scrollbar_style();
3114 let detail_scroll_offset = self.detail_scroll.offset;
3115 let detail_bg = self.style.overlay_bg_dark;
3116 let mut body_page_size: usize = 0;
3117
3118 self.render_overlay_container(
3119 frame,
3120 app_area,
3121 &title,
3122 Some(STATE_DETAIL_HINTS),
3123 None,
3124 |frame, body_area| {
3125 body_page_size = body_area.height as usize;
3126 if body_area.height > 0 {
3127 render_scroll_view(
3128 frame,
3129 body_area,
3130 &content_lines,
3131 detail_scroll_offset,
3132 Some(detail_bg),
3133 &scrollbar_style,
3134 );
3135 }
3136 },
3137 );
3138
3139 self.detail_scroll.page_size = body_page_size.max(1);
3140 }
3141
3142 fn component_detail_lines(
3143 &self,
3144 detail: &super::table::ComponentDetailOverlay,
3145 ) -> Vec<ratatui::text::Line<'static>> {
3146 use ratatui::text::{Line, Span};
3147
3148 let label_style = Style::default().fg(self.style.text_secondary);
3149 let value_style = Style::default().fg(self.style.text_primary);
3150 let type_style = Style::default().fg(self.style.neon_cyan);
3151
3152 let mut lines = vec![
3153 Line::from(vec![
3154 Span::styled("Type: ", label_style),
3155 Span::styled(detail.type_name.clone(), type_style),
3156 ]),
3157 Line::from(vec![
3158 Span::styled("Full path: ", label_style),
3159 Span::styled(detail.type_name_full.clone(), value_style),
3160 ]),
3161 Line::from(vec![
3162 Span::styled("ID: ", label_style),
3163 Span::styled(
3164 detail.bound_id.as_deref().unwrap_or("(none)").to_string(),
3165 value_style,
3166 ),
3167 ]),
3168 Line::from(vec![
3169 Span::styled("Area: ", label_style),
3170 Span::styled(
3171 detail
3172 .last_area
3173 .map(|a| format!("{}x{} at ({},{})", a.width, a.height, a.x, a.y))
3174 .unwrap_or_else(|| "(not rendered)".into()),
3175 value_style,
3176 ),
3177 ]),
3178 ];
3179
3180 if !detail.debug_entries.is_empty() {
3181 lines.push(Line::default());
3182 lines.push(Line::from(Span::styled("Debug State:", label_style)));
3183
3184 let syntax_style = DebugSyntaxStyle::from_style(&self.style, value_style);
3185 let max_key = detail
3186 .debug_entries
3187 .iter()
3188 .map(|(k, _)| k.len())
3189 .max()
3190 .unwrap_or(0);
3191
3192 for (key, value) in &detail.debug_entries {
3193 let pretty = super::format::pretty_reformat(value);
3194 let pretty_lines: Vec<&str> = pretty.lines().collect();
3195 let padded_key = format!(" {key:>width$}: ", width = max_key);
3196 if pretty_lines.len() <= 1 {
3197 let mut spans = vec![Span::styled(padded_key, label_style)];
3198 spans.extend(debug_spans(
3199 pretty_lines.first().unwrap_or(&""),
3200 &syntax_style,
3201 ));
3202 lines.push(Line::from(spans));
3203 } else {
3204 let indent = " ".repeat(max_key + 5); let mut first_spans = vec![Span::styled(padded_key, label_style)];
3207 first_spans.extend(debug_spans(pretty_lines[0], &syntax_style));
3208 lines.push(Line::from(first_spans));
3209 for vline in &pretty_lines[1..] {
3210 let mut spans = vec![Span::styled(indent.clone(), value_style)];
3211 spans.extend(debug_spans(vline, &syntax_style));
3212 lines.push(Line::from(spans));
3213 }
3214 }
3215 }
3216 }
3217
3218 lines
3219 }
3220
3221 fn render_component_detail_modal(
3222 &mut self,
3223 frame: &mut Frame,
3224 app_area: Rect,
3225 detail: &super::table::ComponentDetailOverlay,
3226 ) {
3227 let title = format!("Component - {}", detail.type_name);
3228 let content_lines = self.component_detail_lines(detail);
3229 let scrollbar_style = self.component_scrollbar_style();
3230 let detail_scroll_offset = self.detail_scroll.offset;
3231 let detail_bg = self.style.overlay_bg_dark;
3232 let mut body_page_size: usize = 0;
3233
3234 self.render_overlay_container(
3235 frame,
3236 app_area,
3237 &title,
3238 Some(COMPONENT_DETAIL_HINTS),
3239 None,
3240 |frame, body_area| {
3241 body_page_size = body_area.height as usize;
3242 if body_area.height > 0 {
3243 render_scroll_view(
3244 frame,
3245 body_area,
3246 &content_lines,
3247 detail_scroll_offset,
3248 Some(detail_bg),
3249 &scrollbar_style,
3250 );
3251 }
3252 },
3253 );
3254
3255 self.detail_scroll.page_size = body_page_size.max(1);
3256 }
3257
3258 fn build_inspect_overlay(&self, column: u16, row: u16, snapshot: &Buffer) -> DebugTableOverlay {
3259 let mut builder = DebugTableBuilder::new();
3260
3261 builder.push_section("Position");
3262 builder.push_entry("column", column.to_string());
3263 builder.push_entry("row", row.to_string());
3264
3265 if let Some(preview) = inspect_cell(snapshot, column, row) {
3266 builder.set_cell_preview(preview);
3267 }
3268
3269 builder.finish(format!("Inspect ({column}, {row})"))
3270 }
3271}
3272
3273fn filter_tree_nodes(
3278 nodes: &[TreeNode<String, String>],
3279 query: &str,
3280) -> Vec<TreeNode<String, String>> {
3281 if query.is_empty() {
3282 return nodes.to_vec();
3283 }
3284 let q = query.to_lowercase();
3285 nodes
3286 .iter()
3287 .filter_map(|node| {
3288 if node.value.to_lowercase().contains(&q) {
3289 return Some(node.clone());
3290 }
3291 let matching: Vec<_> = node
3292 .children
3293 .iter()
3294 .filter(|c| c.value.to_lowercase().contains(&q))
3295 .cloned()
3296 .collect();
3297 if matching.is_empty() {
3298 None
3299 } else {
3300 Some(TreeNode::with_children(
3301 node.id.clone(),
3302 node.value.clone(),
3303 matching,
3304 ))
3305 }
3306 })
3307 .collect()
3308}
3309
3310fn render_scroll_view(
3314 frame: &mut Frame,
3315 area: Rect,
3316 lines: &[Line<'_>],
3317 scroll_offset: usize,
3318 bg: Option<ratatui::style::Color>,
3319 scrollbar_style: &ComponentScrollbarStyle,
3320) {
3321 let scroll_style = ScrollViewStyle {
3322 base: BaseStyle {
3323 border: None,
3324 padding: Padding::default(),
3325 bg,
3326 fg: None,
3327 },
3328 scrollbar: scrollbar_style.clone(),
3329 };
3330 let scroller = LinesScroller::new(lines);
3331 let mut scroll_view = ScrollView::new();
3332 <ScrollView as tui_dispatch_core::Component<()>>::render(
3333 &mut scroll_view,
3334 frame,
3335 area,
3336 ScrollViewProps {
3337 content_height: scroller.content_height(),
3338 scroll_offset,
3339 is_focused: true,
3340 style: scroll_style,
3341 behavior: ScrollViewBehavior::default(),
3342 on_scroll: Rc::new(|_| ()),
3343 render_content: &mut scroller.renderer(),
3344 },
3345 );
3346}
3347
3348#[derive(Debug, Serialize)]
3349struct DebugStateSnapshot {
3350 title: String,
3351 sections: Vec<DebugStateSnapshotSection>,
3352}
3353
3354#[derive(Debug, Serialize)]
3355struct DebugStateSnapshotSection {
3356 title: String,
3357 entries: Vec<DebugStateSnapshotEntry>,
3358}
3359
3360#[derive(Debug, Serialize)]
3361struct DebugStateSnapshotEntry {
3362 key: String,
3363 value: String,
3364}
3365
3366impl DebugStateSnapshot {
3367 fn from_state<S: DebugState>(state: &S, title: &str) -> Self {
3368 let sections = state
3369 .debug_sections()
3370 .into_iter()
3371 .map(|section| DebugStateSnapshotSection {
3372 title: section.title,
3373 entries: section
3374 .entries
3375 .into_iter()
3376 .map(|entry| DebugStateSnapshotEntry {
3377 key: entry.key,
3378 value: entry.value,
3379 })
3380 .collect(),
3381 })
3382 .collect();
3383
3384 Self {
3385 title: title.to_string(),
3386 sections,
3387 }
3388 }
3389}
3390
3391fn highlight_rect(buf: &mut Buffer, target: Rect, clip: Rect) {
3396 use ratatui::style::Color;
3397
3398 let x0 = target.x.max(clip.x);
3400 let y0 = target.y.max(clip.y);
3401 let x1 = (target.x.saturating_add(target.width)).min(clip.x.saturating_add(clip.width));
3402 let y1 = (target.y.saturating_add(target.height)).min(clip.y.saturating_add(clip.height));
3403
3404 if x1 <= x0 || y1 <= y0 {
3405 return;
3406 }
3407
3408 let border_fg = Color::Rgb(0, 255, 255); let border_style = Style::default().fg(border_fg);
3410
3411 let left = x0;
3412 let right = x1.saturating_sub(1);
3413 let top = y0;
3414 let bottom = y1.saturating_sub(1);
3415
3416 if left <= right && top <= bottom {
3418 let tl = &mut buf[(left, top)];
3419 tl.set_symbol("┌");
3420 tl.set_style(border_style);
3421
3422 if right > left {
3423 let tr = &mut buf[(right, top)];
3424 tr.set_symbol("┐");
3425 tr.set_style(border_style);
3426 }
3427
3428 if bottom > top {
3429 let bl = &mut buf[(left, bottom)];
3430 bl.set_symbol("└");
3431 bl.set_style(border_style);
3432
3433 if right > left {
3434 let br = &mut buf[(right, bottom)];
3435 br.set_symbol("┘");
3436 br.set_style(border_style);
3437 }
3438 }
3439 }
3440
3441 for x in (left + 1)..right {
3443 let cell = &mut buf[(x, top)];
3444 cell.set_symbol("─");
3445 cell.set_style(border_style);
3446
3447 if bottom > top {
3448 let cell = &mut buf[(x, bottom)];
3449 cell.set_symbol("─");
3450 cell.set_style(border_style);
3451 }
3452 }
3453
3454 for y in (top + 1)..bottom {
3456 let cell = &mut buf[(left, y)];
3457 cell.set_symbol("│");
3458 cell.set_style(border_style);
3459
3460 if right > left {
3461 let cell = &mut buf[(right, y)];
3462 cell.set_symbol("│");
3463 cell.set_style(border_style);
3464 }
3465 }
3466}
3467
3468fn pad_text(value: &str, width: usize) -> String {
3469 if width == 0 {
3470 return String::new();
3471 }
3472 let mut text: String = value.chars().take(width).collect();
3473 let len = text.chars().count();
3474 if len < width {
3475 text.push_str(&" ".repeat(width - len));
3476 }
3477 text
3478}
3479
3480fn truncate_with_ellipsis(value: &str, width: usize) -> String {
3481 if width == 0 {
3482 return String::new();
3483 }
3484 let count = value.chars().count();
3485 if count <= width {
3486 return value.to_string();
3487 }
3488 if width <= 3 {
3489 return value.chars().take(width).collect();
3490 }
3491 let mut text: String = value.chars().take(width - 3).collect();
3492 text.push_str("...");
3493 text
3494}
3495
3496fn format_key(key: KeyCode) -> String {
3498 match key {
3499 KeyCode::F(n) => format!("F{}", n),
3500 KeyCode::Char(c) => c.to_string(),
3501 KeyCode::Esc => "Esc".to_string(),
3502 KeyCode::Enter => "Enter".to_string(),
3503 KeyCode::Tab => "Tab".to_string(),
3504 KeyCode::Backspace => "Bksp".to_string(),
3505 KeyCode::Delete => "Del".to_string(),
3506 KeyCode::Up => "↑".to_string(),
3507 KeyCode::Down => "↓".to_string(),
3508 KeyCode::Left => "←".to_string(),
3509 KeyCode::Right => "→".to_string(),
3510 _ => format!("{:?}", key),
3511 }
3512}
3513
3514#[cfg(test)]
3515mod tests {
3516 use super::*;
3517
3518 #[derive(Debug, Clone)]
3519 enum TestAction {
3520 Foo,
3521 Bar,
3522 }
3523
3524 impl tui_dispatch_core::Action for TestAction {
3525 fn name(&self) -> &'static str {
3526 match self {
3527 TestAction::Foo => "Foo",
3528 TestAction::Bar => "Bar",
3529 }
3530 }
3531 }
3532
3533 impl tui_dispatch_core::ActionParams for TestAction {
3534 fn params(&self) -> String {
3535 String::new()
3536 }
3537 }
3538
3539 #[test]
3540 fn test_debug_layer_creation() {
3541 let layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
3542 assert!(!layer.is_enabled());
3543 assert!(layer.freeze().snapshot.is_none());
3544 }
3545
3546 #[test]
3547 fn test_toggle() {
3548 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
3549
3550 let effect = layer.toggle();
3552 assert!(effect.is_none());
3553 assert!(layer.is_enabled());
3554
3555 let effect = layer.toggle();
3557 assert!(effect.is_none()); assert!(!layer.is_enabled());
3559 }
3560
3561 #[test]
3562 fn test_set_enabled() {
3563 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
3564
3565 layer.set_enabled(true);
3566 assert!(layer.is_enabled());
3567
3568 layer.set_enabled(false);
3569 assert!(!layer.is_enabled());
3570 }
3571
3572 #[test]
3573 fn test_simple_constructor() {
3574 let layer: DebugLayer<TestAction> = DebugLayer::simple();
3575 assert!(!layer.is_enabled());
3576 }
3577
3578 #[test]
3579 fn test_queued_actions_returned_on_disable() {
3580 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
3581
3582 layer.toggle(); layer.queue_action(TestAction::Foo);
3584 layer.queue_action(TestAction::Bar);
3585
3586 let effect = layer.toggle(); match effect {
3589 Some(DebugSideEffect::ProcessQueuedActions(actions)) => {
3590 assert_eq!(actions.len(), 2);
3591 }
3592 _ => panic!("Expected ProcessQueuedActions"),
3593 }
3594 }
3595
3596 #[test]
3597 fn test_split_area() {
3598 let layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
3599
3600 let area = Rect::new(0, 0, 80, 24);
3602 let (app, banner) = layer.split_area(area);
3603 assert_eq!(app, area);
3604 assert_eq!(banner, Rect::ZERO);
3605 }
3606
3607 #[test]
3608 fn test_split_area_enabled() {
3609 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
3610 layer.toggle();
3611
3612 let area = Rect::new(0, 0, 80, 24);
3613 let (app, banner) = layer.split_area(area);
3614
3615 assert_eq!(app.height, 23);
3616 assert_eq!(banner.height, 1);
3617 assert_eq!(banner.y, 23);
3618 }
3619
3620 #[test]
3621 fn test_split_area_enabled_top() {
3622 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
3623 layer.toggle();
3624 layer.set_banner_position(BannerPosition::Top);
3625
3626 let area = Rect::new(0, 0, 80, 24);
3627 let (app, banner) = layer.split_area(area);
3628
3629 assert_eq!(banner.y, 0);
3630 assert_eq!(banner.height, 1);
3631 assert_eq!(app.y, 1);
3632 assert_eq!(app.height, 23);
3633 }
3634
3635 #[test]
3636 fn test_inactive_layer() {
3637 let layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12)).active(false);
3638
3639 assert!(!layer.is_active());
3640 assert!(!layer.is_enabled());
3641 }
3642
3643 #[test]
3644 fn test_action_log() {
3645 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
3646
3647 layer.log_action(&TestAction::Foo);
3648 layer.log_action(&TestAction::Bar);
3649
3650 assert_eq!(layer.action_log().entries().count(), 2);
3651 }
3652
3653 #[test]
3654 fn test_action_log_filter_configuration() {
3655 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12))
3656 .with_action_log_filter(ActionLoggerConfig::new(Some("name:Foo"), None));
3657
3658 layer.log_action(&TestAction::Foo);
3659 layer.log_action(&TestAction::Bar);
3660
3661 let names: Vec<_> = layer
3662 .action_log()
3663 .entries()
3664 .map(|entry| entry.name)
3665 .collect();
3666 assert_eq!(names, vec!["Foo"]);
3667 }
3668
3669 #[test]
3670 fn test_action_log_search_input_does_not_trigger_global_shortcuts() {
3671 use crossterm::event::{KeyEventKind, KeyEventState};
3672 use tui_dispatch_core::EventKind;
3673
3674 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
3675 layer.toggle();
3676 layer.show_action_log();
3677
3678 if let Some(DebugOverlay::ActionLog(log)) = layer.freeze.overlay.as_mut() {
3679 log.search_input_active = true;
3680 } else {
3681 panic!("expected action log overlay");
3682 }
3683
3684 let keys = [
3685 KeyEvent {
3686 code: KeyCode::Char('A'),
3687 modifiers: KeyModifiers::SHIFT,
3688 kind: KeyEventKind::Press,
3689 state: KeyEventState::NONE,
3690 },
3691 KeyEvent {
3692 code: KeyCode::Char('s'),
3693 modifiers: KeyModifiers::NONE,
3694 kind: KeyEventKind::Press,
3695 state: KeyEventState::NONE,
3696 },
3697 KeyEvent {
3698 code: KeyCode::Char('b'),
3699 modifiers: KeyModifiers::NONE,
3700 kind: KeyEventKind::Press,
3701 state: KeyEventState::NONE,
3702 },
3703 KeyEvent {
3704 code: KeyCode::Char('i'),
3705 modifiers: KeyModifiers::NONE,
3706 kind: KeyEventKind::Press,
3707 state: KeyEventState::NONE,
3708 },
3709 ];
3710 for key in keys {
3711 let outcome = layer.handle_event(&EventKind::Key(key));
3712 assert!(outcome.consumed);
3713 }
3714
3715 match layer.freeze.overlay.as_ref() {
3716 Some(DebugOverlay::ActionLog(log)) => {
3717 assert!(log.search_input_active);
3718 assert_eq!(log.search_query, "Asbi");
3719 }
3720 _ => panic!("action log overlay should remain open"),
3721 }
3722 }
3723
3724 #[test]
3725 fn test_esc_from_state_detail_returns_to_tree() {
3726 use crossterm::event::{KeyEventKind, KeyEventState};
3727 use tui_dispatch_core::EventKind;
3728
3729 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
3730 layer.toggle();
3731
3732 let table = DebugTableBuilder::new()
3733 .section("State")
3734 .entry("key", "value")
3735 .finish("Application State");
3736 layer.set_state_overlay(table);
3737 layer.state_tree_selected = Some("entry:0:0:key".to_string());
3738 layer.handle_action(DebugAction::StateTreeShowDetail);
3739
3740 assert!(matches!(
3741 layer.freeze.overlay,
3742 Some(DebugOverlay::StateDetail(_))
3743 ));
3744
3745 let outcome = layer.handle_event(&EventKind::Key(KeyEvent {
3746 code: KeyCode::Esc,
3747 modifiers: KeyModifiers::NONE,
3748 kind: KeyEventKind::Press,
3749 state: KeyEventState::NONE,
3750 }));
3751
3752 assert!(outcome.consumed);
3753 assert!(layer.is_enabled());
3754 assert!(matches!(layer.freeze.overlay, Some(DebugOverlay::State(_))));
3755 }
3756
3757 #[test]
3758 fn test_esc_from_component_detail_returns_to_list() {
3759 use crossterm::event::{KeyEventKind, KeyEventState};
3760 use tui_dispatch_core::EventKind;
3761
3762 let mut layer: DebugLayer<TestAction> = DebugLayer::new(KeyCode::F(12));
3763 layer.toggle();
3764 layer.component_snapshotter = Some(Box::new(|| {
3765 vec![super::super::table::ComponentSnapshot {
3766 raw_id: 1,
3767 type_name: "Widget".to_string(),
3768 type_name_full: "crate::ui::Widget".to_string(),
3769 bound_id: Some("root".to_string()),
3770 last_area: None,
3771 debug_entries: vec![("state".to_string(), "Ready".to_string())],
3772 }]
3773 }));
3774 layer.show_components();
3775 layer.components_tree_selected = Some("comp:0".to_string());
3776 layer.handle_action(DebugAction::ComponentShowDetail);
3777
3778 assert!(matches!(
3779 layer.freeze.overlay,
3780 Some(DebugOverlay::ComponentDetail(_))
3781 ));
3782
3783 let outcome = layer.handle_event(&EventKind::Key(KeyEvent {
3784 code: KeyCode::Esc,
3785 modifiers: KeyModifiers::NONE,
3786 kind: KeyEventKind::Press,
3787 state: KeyEventState::NONE,
3788 }));
3789
3790 assert!(outcome.consumed);
3791 assert!(layer.is_enabled());
3792 assert!(matches!(
3793 layer.freeze.overlay,
3794 Some(DebugOverlay::Components(_))
3795 ));
3796 }
3797}