1use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
6use ratatui::buffer::Buffer;
7use ratatui::layout::Rect;
8use ratatui::widgets::{Block, Borders, Clear};
9use ratatui::Frame;
10use std::marker::PhantomData;
11
12use super::actions::{DebugAction, DebugSideEffect};
13use super::cell::inspect_cell;
14use super::config::{DebugConfig, StatusItem};
15use super::state::DebugState;
16use super::table::{DebugOverlay, DebugTableBuilder, DebugTableOverlay};
17use super::widgets::{dim_buffer, paint_snapshot, BannerItem, DebugBanner, DebugTableWidget};
18use super::DebugFreeze;
19use crate::keybindings::{format_key_for_display, BindingContext};
20
21pub struct DebugLayer<A, C: BindingContext> {
51 freeze: DebugFreeze<A>,
53 config: DebugConfig<C>,
55}
56
57impl<A, C: BindingContext> std::fmt::Debug for DebugLayer<A, C> {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 f.debug_struct("DebugLayer")
60 .field("enabled", &self.freeze.enabled)
61 .field("has_snapshot", &self.freeze.snapshot.is_some())
62 .field("queued_actions", &self.freeze.queued_actions.len())
63 .finish()
64 }
65}
66
67impl<A, C: BindingContext> DebugLayer<A, C> {
68 pub fn new(config: DebugConfig<C>) -> Self {
70 Self {
71 freeze: DebugFreeze::new(),
72 config,
73 }
74 }
75
76 pub fn is_enabled(&self) -> bool {
78 self.freeze.enabled
79 }
80
81 pub fn freeze(&self) -> &DebugFreeze<A> {
83 &self.freeze
84 }
85
86 pub fn freeze_mut(&mut self) -> &mut DebugFreeze<A> {
88 &mut self.freeze
89 }
90
91 pub fn config(&self) -> &DebugConfig<C> {
93 &self.config
94 }
95
96 pub fn config_mut(&mut self) -> &mut DebugConfig<C> {
98 &mut self.config
99 }
100
101 pub fn render<F>(&mut self, frame: &mut Frame, render_fn: F)
121 where
122 F: FnOnce(&mut Frame, Rect),
123 {
124 let screen = frame.area();
125
126 if !self.freeze.enabled {
127 render_fn(frame, screen);
129 return;
130 }
131
132 let (app_area, banner_area) = self.split_for_banner(screen);
134
135 if self.freeze.pending_capture || self.freeze.snapshot.is_none() {
136 render_fn(frame, app_area);
138 let buffer_clone = frame.buffer_mut().clone();
140 self.freeze.capture(&buffer_clone);
141 } else if let Some(ref snapshot) = self.freeze.snapshot {
142 paint_snapshot(frame, snapshot);
144 }
145
146 self.render_debug_overlay(frame, app_area, banner_area);
148 }
149
150 pub fn split_area(&self, area: Rect) -> (Rect, Rect) {
172 if !self.freeze.enabled {
173 return (area, Rect::ZERO);
174 }
175 self.split_for_banner(area)
176 }
177
178 pub fn render_overlay(&self, frame: &mut Frame, app_area: Rect) {
182 if !self.freeze.enabled {
183 return;
184 }
185
186 dim_buffer(frame.buffer_mut(), self.config.style.dim_factor);
188
189 if let Some(ref overlay) = self.freeze.overlay {
191 self.render_modal(frame, app_area, overlay.table());
192 }
193 }
194
195 pub fn render_banner(&self, frame: &mut Frame, banner_area: Rect) {
199 if !self.freeze.enabled || banner_area.height == 0 {
200 return;
201 }
202
203 let style = &self.config.style;
204 let mut banner = DebugBanner::new()
205 .title("DEBUG")
206 .title_style(style.title_style)
207 .label_style(style.label_style)
208 .background(style.banner_bg);
209
210 self.add_banner_item(&mut banner, DebugAction::CMD_TOGGLE, "resume");
212 self.add_banner_item(&mut banner, DebugAction::CMD_TOGGLE_STATE, "state");
213 self.add_banner_item(&mut banner, DebugAction::CMD_COPY_FRAME, "copy");
214
215 if self.freeze.mouse_capture_enabled {
216 banner = banner.item(BannerItem::new("click", "inspect", style.key_style));
217 } else {
218 self.add_banner_item(&mut banner, DebugAction::CMD_TOGGLE_MOUSE, "mouse");
219 }
220
221 if let Some(ref msg) = self.freeze.message {
223 banner = banner.item(BannerItem::new("", msg, style.value_style));
224 }
225
226 frame.render_widget(banner, banner_area);
231 }
232
233 pub fn render_banner_with_status(
238 &self,
239 frame: &mut Frame,
240 banner_area: Rect,
241 status_items: &[(&str, &str)],
242 ) {
243 if !self.freeze.enabled || banner_area.height == 0 {
244 return;
245 }
246
247 let style = &self.config.style;
248 let mut banner = DebugBanner::new()
249 .title("DEBUG")
250 .title_style(style.title_style)
251 .label_style(style.label_style)
252 .background(style.banner_bg);
253
254 self.add_banner_item(&mut banner, DebugAction::CMD_TOGGLE, "resume");
256 self.add_banner_item(&mut banner, DebugAction::CMD_TOGGLE_STATE, "state");
257 self.add_banner_item(&mut banner, DebugAction::CMD_COPY_FRAME, "copy");
258
259 if self.freeze.mouse_capture_enabled {
260 banner = banner.item(BannerItem::new("click", "inspect", style.key_style));
261 } else {
262 self.add_banner_item(&mut banner, DebugAction::CMD_TOGGLE_MOUSE, "mouse");
263 }
264
265 if let Some(ref msg) = self.freeze.message {
267 banner = banner.item(BannerItem::new("", msg, style.value_style));
268 }
269
270 for (label, value) in status_items {
272 banner = banner.item(BannerItem::new(label, value, style.value_style));
273 }
274
275 frame.render_widget(banner, banner_area);
276 }
277
278 pub fn handle_action(&mut self, action: DebugAction) -> Option<DebugSideEffect<A>> {
282 match action {
283 DebugAction::Toggle => {
284 if self.freeze.enabled {
285 let queued = self.freeze.take_queued();
286 self.freeze.disable();
287 if queued.is_empty() {
288 None
289 } else {
290 Some(DebugSideEffect::ProcessQueuedActions(queued))
291 }
292 } else {
293 self.freeze.enable();
294 None
295 }
296 }
297 DebugAction::CopyFrame => {
298 let text = self.freeze.snapshot_text.clone();
299 self.freeze.set_message("Copied to clipboard");
300 Some(DebugSideEffect::CopyToClipboard(text))
301 }
302 DebugAction::ToggleState => {
303 if self.freeze.overlay.is_some() {
305 self.freeze.clear_overlay();
306 } else {
307 let table = DebugTableBuilder::new()
310 .section("State")
311 .entry("hint", "Call show_state_overlay() with your state")
312 .finish("Application State");
313 self.freeze.set_overlay(DebugOverlay::State(table));
314 }
315 None
316 }
317 DebugAction::ToggleMouseCapture => {
318 self.freeze.toggle_mouse_capture();
319 if self.freeze.mouse_capture_enabled {
320 Some(DebugSideEffect::EnableMouseCapture)
321 } else {
322 Some(DebugSideEffect::DisableMouseCapture)
323 }
324 }
325 DebugAction::InspectCell { column, row } => {
326 if let Some(ref snapshot) = self.freeze.snapshot {
327 let overlay = self.build_inspect_overlay(column, row, snapshot);
328 self.freeze.set_overlay(DebugOverlay::Inspect(overlay));
329 }
330 self.freeze.mouse_capture_enabled = false;
331 Some(DebugSideEffect::DisableMouseCapture)
332 }
333 DebugAction::CloseOverlay => {
334 self.freeze.clear_overlay();
335 None
336 }
337 DebugAction::RequestCapture => {
338 self.freeze.request_capture();
339 None
340 }
341 }
342 }
343
344 pub fn handle_mouse(&mut self, mouse: MouseEvent) -> Option<DebugSideEffect<A>> {
348 if !self.freeze.enabled {
349 return None;
350 }
351
352 if matches!(
354 mouse.kind,
355 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
356 ) {
357 return None;
358 }
359
360 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
362 && self.freeze.mouse_capture_enabled
363 {
364 return self.handle_action(DebugAction::InspectCell {
365 column: mouse.column,
366 row: mouse.row,
367 });
368 }
369
370 None
371 }
372
373 pub fn show_state_overlay<S: DebugState>(&mut self, state: &S) {
375 let table = state.build_debug_table("Application State");
376 self.freeze.set_overlay(DebugOverlay::State(table));
377 }
378
379 pub fn show_state_overlay_with_title<S: DebugState>(&mut self, state: &S, title: &str) {
381 let table = state.build_debug_table(title);
382 self.freeze.set_overlay(DebugOverlay::State(table));
383 }
384
385 pub fn queue_action(&mut self, action: A) {
387 self.freeze.queue(action);
388 }
389
390 fn split_for_banner(&self, area: Rect) -> (Rect, Rect) {
393 let banner_height = 1;
394 let app_area = Rect {
395 height: area.height.saturating_sub(banner_height),
396 ..area
397 };
398 let banner_area = Rect {
399 y: area.y.saturating_add(app_area.height),
400 height: banner_height.min(area.height),
401 ..area
402 };
403 (app_area, banner_area)
404 }
405
406 fn render_debug_overlay(&self, frame: &mut Frame, app_area: Rect, banner_area: Rect) {
407 dim_buffer(frame.buffer_mut(), self.config.style.dim_factor);
409
410 if let Some(ref overlay) = self.freeze.overlay {
412 self.render_modal(frame, app_area, overlay.table());
413 }
414
415 self.render_banner(frame, banner_area);
417 }
418
419 fn render_modal(&self, frame: &mut Frame, app_area: Rect, table: &DebugTableOverlay) {
420 let modal_width = (app_area.width * 80 / 100)
422 .clamp(30, 120)
423 .min(app_area.width);
424 let modal_height = (app_area.height * 60 / 100)
425 .clamp(8, 40)
426 .min(app_area.height);
427
428 let modal_x = app_area.x + (app_area.width.saturating_sub(modal_width)) / 2;
430 let modal_y = app_area.y + (app_area.height.saturating_sub(modal_height)) / 2;
431
432 let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
433
434 frame.render_widget(Clear, modal_area);
436
437 let block = Block::default()
438 .borders(Borders::ALL)
439 .title(format!(" {} ", table.title))
440 .style(self.config.style.banner_bg);
441
442 let inner = block.inner(modal_area);
443 frame.render_widget(block, modal_area);
444
445 let table_widget = DebugTableWidget::new(table);
447 frame.render_widget(table_widget, inner);
448 }
449
450 fn add_banner_item(&self, banner: &mut DebugBanner<'_>, command: &str, label: &'static str) {
451 if let Some(key) = self
452 .config
453 .keybindings
454 .get_first_keybinding(command, self.config.debug_context)
455 {
456 let formatted = format_key_for_display(&key);
457 let key_str: &'static str = Box::leak(formatted.into_boxed_str());
459 *banner = std::mem::take(banner).item(BannerItem::new(
460 key_str,
461 label,
462 self.config.style.key_style,
463 ));
464 }
465 }
466
467 fn build_inspect_overlay(&self, column: u16, row: u16, snapshot: &Buffer) -> DebugTableOverlay {
468 let mut builder = DebugTableBuilder::new();
469
470 builder.push_section("Position");
471 builder.push_entry("column", column.to_string());
472 builder.push_entry("row", row.to_string());
473
474 if let Some(preview) = inspect_cell(snapshot, column, row) {
475 builder.set_cell_preview(preview);
476 }
477
478 builder.finish(format!("Inspect ({column}, {row})"))
479 }
480}
481
482pub struct DebugLayerBuilder<A, C: BindingContext> {
484 config: DebugConfig<C>,
485 _marker: PhantomData<A>,
486}
487
488impl<A, C: BindingContext> DebugLayerBuilder<A, C> {
489 pub fn new(config: DebugConfig<C>) -> Self {
491 Self {
492 config,
493 _marker: PhantomData,
494 }
495 }
496
497 pub fn with_status_provider<F>(mut self, provider: F) -> Self
499 where
500 F: Fn() -> Vec<StatusItem> + Send + Sync + 'static,
501 {
502 self.config = self.config.with_status_provider(provider);
503 self
504 }
505
506 pub fn build(self) -> DebugLayer<A, C> {
508 DebugLayer::new(self.config)
509 }
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515 use crate::keybindings::Keybindings;
516
517 #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
519 enum TestContext {
520 Debug,
521 }
522
523 impl BindingContext for TestContext {
524 fn name(&self) -> &'static str {
525 "debug"
526 }
527 fn from_name(name: &str) -> Option<Self> {
528 (name == "debug").then_some(TestContext::Debug)
529 }
530 fn all() -> &'static [Self] {
531 &[TestContext::Debug]
532 }
533 }
534
535 #[derive(Debug, Clone)]
536 enum TestAction {
537 Foo,
538 Bar,
539 }
540
541 fn make_layer() -> DebugLayer<TestAction, TestContext> {
542 let config = DebugConfig::new(Keybindings::new(), TestContext::Debug);
543 DebugLayer::new(config)
544 }
545
546 #[test]
547 fn test_debug_layer_creation() {
548 let layer = make_layer();
549 assert!(!layer.is_enabled());
550 assert!(layer.freeze().snapshot.is_none());
551 }
552
553 #[test]
554 fn test_toggle() {
555 let mut layer = make_layer();
556
557 let effect = layer.handle_action(DebugAction::Toggle);
559 assert!(effect.is_none());
560 assert!(layer.is_enabled());
561
562 let effect = layer.handle_action(DebugAction::Toggle);
564 assert!(effect.is_none()); assert!(!layer.is_enabled());
566 }
567
568 #[test]
569 fn test_queued_actions_returned_on_disable() {
570 let mut layer = make_layer();
571
572 layer.handle_action(DebugAction::Toggle); layer.queue_action(TestAction::Foo);
574 layer.queue_action(TestAction::Bar);
575
576 let effect = layer.handle_action(DebugAction::Toggle); match effect {
579 Some(DebugSideEffect::ProcessQueuedActions(actions)) => {
580 assert_eq!(actions.len(), 2);
581 }
582 _ => panic!("Expected ProcessQueuedActions"),
583 }
584 }
585
586 #[test]
587 fn test_split_area() {
588 let layer = make_layer();
589
590 let area = Rect::new(0, 0, 80, 24);
592 let (app, banner) = layer.split_area(area);
593 assert_eq!(app, area);
594 assert_eq!(banner, Rect::ZERO);
595 }
596
597 #[test]
598 fn test_split_area_enabled() {
599 let mut layer = make_layer();
600 layer.handle_action(DebugAction::Toggle);
601
602 let area = Rect::new(0, 0, 80, 24);
603 let (app, banner) = layer.split_area(area);
604
605 assert_eq!(app.height, 23);
606 assert_eq!(banner.height, 1);
607 assert_eq!(banner.y, 23);
608 }
609
610 #[test]
611 fn test_mouse_capture_toggle() {
612 let mut layer = make_layer();
613 layer.handle_action(DebugAction::Toggle); let effect = layer.handle_action(DebugAction::ToggleMouseCapture);
616 assert!(matches!(effect, Some(DebugSideEffect::EnableMouseCapture)));
617 assert!(layer.freeze().mouse_capture_enabled);
618
619 let effect = layer.handle_action(DebugAction::ToggleMouseCapture);
620 assert!(matches!(effect, Some(DebugSideEffect::DisableMouseCapture)));
621 assert!(!layer.freeze().mouse_capture_enabled);
622 }
623}