tui_dispatch_core/debug/
widgets.rs

1//! Debug rendering utilities and widgets
2//!
3//! Provides theme-agnostic widgets for rendering debug information.
4//! Applications provide their own styles to customize appearance.
5
6use ratatui::buffer::Buffer;
7use ratatui::layout::{Constraint, Rect};
8use ratatui::style::{Modifier, Style};
9use ratatui::text::{Line, Span};
10use ratatui::widgets::{Block, Cell, Clear, Paragraph, Row, Table, Widget};
11use ratatui::Frame;
12
13use super::cell::{format_color_compact, format_modifier_compact, CellPreview};
14use super::table::{ActionLogOverlay, DebugTableOverlay, DebugTableRow};
15
16/// Convert a buffer to plain text (for clipboard export)
17///
18/// Trims trailing whitespace from each line.
19pub fn buffer_to_text(buffer: &Buffer) -> String {
20    let area = buffer.area;
21    let mut out = String::new();
22
23    for y in area.y..area.y.saturating_add(area.height) {
24        let mut line = String::new();
25        for x in area.x..area.x.saturating_add(area.width) {
26            line.push_str(buffer[(x, y)].symbol());
27        }
28        out.push_str(line.trim_end_matches(' '));
29        if y + 1 < area.y.saturating_add(area.height) {
30            out.push('\n');
31        }
32    }
33
34    out
35}
36
37/// Paint a snapshot buffer onto the current frame
38///
39/// Clears the frame first, then copies cells from the snapshot.
40pub fn paint_snapshot(f: &mut Frame, snapshot: &Buffer) {
41    let screen = f.area();
42    f.render_widget(Clear, screen);
43
44    let snap_area = snapshot.area;
45    let x_end = screen
46        .x
47        .saturating_add(screen.width)
48        .min(snap_area.x.saturating_add(snap_area.width));
49    let y_end = screen
50        .y
51        .saturating_add(screen.height)
52        .min(snap_area.y.saturating_add(snap_area.height));
53
54    for y in screen.y..y_end {
55        for x in screen.x..x_end {
56            f.buffer_mut()[(x, y)] = snapshot[(x, y)].clone();
57        }
58    }
59}
60
61/// Dim a buffer by blending with a darker shade
62///
63/// `factor` ranges from 0.0 (no change) to 1.0 (fully dimmed)
64pub fn dim_buffer(buffer: &mut Buffer, factor: f32) {
65    let factor = factor.clamp(0.0, 1.0);
66    let dim_amount = (255.0 * factor) as u8;
67
68    for cell in buffer.content.iter_mut() {
69        if let ratatui::style::Color::Rgb(r, g, b) = cell.bg {
70            cell.bg = ratatui::style::Color::Rgb(
71                r.saturating_sub(dim_amount),
72                g.saturating_sub(dim_amount),
73                b.saturating_sub(dim_amount),
74            );
75        }
76    }
77}
78
79/// An item in a debug banner (status bar)
80#[derive(Clone)]
81pub struct BannerItem<'a> {
82    /// The key/label shown in a highlighted style
83    pub key: &'a str,
84    /// The description shown after the key
85    pub label: &'a str,
86    /// Style for the key
87    pub key_style: Style,
88}
89
90impl<'a> BannerItem<'a> {
91    /// Create a new banner item
92    pub fn new(key: &'a str, label: &'a str, key_style: Style) -> Self {
93        Self {
94            key,
95            label,
96            key_style,
97        }
98    }
99}
100
101/// A debug banner widget (status bar at bottom)
102///
103/// # Example
104///
105/// ```ignore
106/// let banner = DebugBanner::new()
107///     .title("DEBUG")
108///     .item(BannerItem::new("F12", "resume", key_style))
109///     .item(BannerItem::new("S", "state", key_style))
110///     .background(bg_style);
111///
112/// f.render_widget(banner, area);
113/// ```
114pub struct DebugBanner<'a> {
115    title: Option<&'a str>,
116    title_style: Style,
117    items: Vec<BannerItem<'a>>,
118    label_style: Style,
119    background: Style,
120}
121
122impl<'a> Default for DebugBanner<'a> {
123    fn default() -> Self {
124        Self::new()
125    }
126}
127
128impl<'a> DebugBanner<'a> {
129    /// Create a new empty debug banner
130    pub fn new() -> Self {
131        Self {
132            title: None,
133            title_style: Style::default(),
134            items: Vec::new(),
135            label_style: Style::default(),
136            background: Style::default(),
137        }
138    }
139
140    /// Set the banner title (e.g., "DEBUG")
141    pub fn title(mut self, title: &'a str) -> Self {
142        self.title = Some(title);
143        self
144    }
145
146    /// Set the title style
147    pub fn title_style(mut self, style: Style) -> Self {
148        self.title_style = style;
149        self
150    }
151
152    /// Add an item to the banner
153    pub fn item(mut self, item: BannerItem<'a>) -> Self {
154        self.items.push(item);
155        self
156    }
157
158    /// Set the style for item labels
159    pub fn label_style(mut self, style: Style) -> Self {
160        self.label_style = style;
161        self
162    }
163
164    /// Set the background style
165    pub fn background(mut self, style: Style) -> Self {
166        self.background = style;
167        self
168    }
169}
170
171impl Widget for DebugBanner<'_> {
172    fn render(self, area: Rect, buf: &mut Buffer) {
173        if area.height == 0 || area.width == 0 {
174            return;
175        }
176
177        // Fill background
178        Block::default().style(self.background).render(area, buf);
179
180        let mut spans = Vec::new();
181
182        // Add title if present
183        if let Some(title) = self.title {
184            spans.push(Span::styled(format!(" {title} "), self.title_style));
185            spans.push(Span::raw(" "));
186        }
187
188        // Add items
189        for item in &self.items {
190            spans.push(Span::styled(format!(" {} ", item.key), item.key_style));
191            spans.push(Span::styled(format!(" {} ", item.label), self.label_style));
192        }
193
194        let line = Paragraph::new(Line::from(spans)).style(self.background);
195        line.render(area, buf);
196    }
197}
198
199/// Style configuration for debug table rendering
200#[derive(Clone)]
201pub struct DebugTableStyle {
202    /// Style for the header row
203    pub header: Style,
204    /// Style for section titles
205    pub section: Style,
206    /// Style for entry keys
207    pub key: Style,
208    /// Style for entry values
209    pub value: Style,
210    /// Alternating row styles (even, odd)
211    pub row_styles: (Style, Style),
212}
213
214impl Default for DebugTableStyle {
215    fn default() -> Self {
216        use super::config::DebugStyle;
217        Self {
218            header: Style::default()
219                .fg(DebugStyle::neon_cyan())
220                .add_modifier(Modifier::BOLD),
221            section: Style::default()
222                .fg(DebugStyle::neon_purple())
223                .add_modifier(Modifier::BOLD),
224            key: Style::default()
225                .fg(DebugStyle::neon_amber())
226                .add_modifier(Modifier::BOLD),
227            value: Style::default().fg(DebugStyle::text_primary()),
228            row_styles: (
229                Style::default().bg(DebugStyle::bg_panel()),
230                Style::default().bg(DebugStyle::bg_surface()),
231            ),
232        }
233    }
234}
235
236/// A debug table widget that renders a DebugTableOverlay
237pub struct DebugTableWidget<'a> {
238    table: &'a DebugTableOverlay,
239    style: DebugTableStyle,
240}
241
242impl<'a> DebugTableWidget<'a> {
243    /// Create a new debug table widget
244    pub fn new(table: &'a DebugTableOverlay) -> Self {
245        Self {
246            table,
247            style: DebugTableStyle::default(),
248        }
249    }
250
251    /// Set the style configuration
252    pub fn style(mut self, style: DebugTableStyle) -> Self {
253        self.style = style;
254        self
255    }
256}
257
258impl Widget for DebugTableWidget<'_> {
259    fn render(self, area: Rect, buf: &mut Buffer) {
260        if area.height < 2 || area.width < 10 {
261            return;
262        }
263
264        // Calculate column widths
265        let max_key_len = self
266            .table
267            .rows
268            .iter()
269            .filter_map(|row| match row {
270                DebugTableRow::Entry { key, .. } => Some(key.chars().count()),
271                DebugTableRow::Section(_) => None,
272            })
273            .max()
274            .unwrap_or(0) as u16;
275
276        let max_label = area.width.saturating_sub(8).max(10);
277        let label_width = max_key_len.saturating_add(2).clamp(12, 30).min(max_label);
278        let constraints = [Constraint::Length(label_width), Constraint::Min(0)];
279
280        // Build header
281        let header = Row::new(vec![
282            Cell::from("Field").style(self.style.header),
283            Cell::from("Value").style(self.style.header),
284        ]);
285
286        // Build rows
287        let rows: Vec<Row> = self
288            .table
289            .rows
290            .iter()
291            .enumerate()
292            .map(|(idx, row)| match row {
293                DebugTableRow::Section(title) => Row::new(vec![
294                    Cell::from(format!(" {title} ")).style(self.style.section),
295                    Cell::from(""),
296                ]),
297                DebugTableRow::Entry { key, value } => {
298                    let row_style = if idx % 2 == 0 {
299                        self.style.row_styles.0
300                    } else {
301                        self.style.row_styles.1
302                    };
303                    Row::new(vec![
304                        Cell::from(key.clone()).style(self.style.key),
305                        Cell::from(value.clone()).style(self.style.value),
306                    ])
307                    .style(row_style)
308                }
309            })
310            .collect();
311
312        let table = Table::new(rows, constraints)
313            .header(header)
314            .column_spacing(2);
315
316        table.render(area, buf);
317    }
318}
319
320/// A widget that renders a cell preview
321pub struct CellPreviewWidget<'a> {
322    preview: &'a CellPreview,
323    label_style: Style,
324    value_style: Style,
325}
326
327impl<'a> CellPreviewWidget<'a> {
328    /// Create a new cell preview widget with default neon styling
329    pub fn new(preview: &'a CellPreview) -> Self {
330        use super::config::DebugStyle;
331        Self {
332            preview,
333            label_style: Style::default().fg(DebugStyle::text_secondary()),
334            value_style: Style::default().fg(DebugStyle::text_primary()),
335        }
336    }
337
338    /// Set the style for labels (fg, bg, etc.)
339    pub fn label_style(mut self, style: Style) -> Self {
340        self.label_style = style;
341        self
342    }
343
344    /// Set the style for values
345    pub fn value_style(mut self, style: Style) -> Self {
346        self.value_style = style;
347        self
348    }
349}
350
351impl Widget for CellPreviewWidget<'_> {
352    fn render(self, area: Rect, buf: &mut Buffer) {
353        use super::config::DebugStyle;
354
355        if area.width < 20 || area.height < 1 {
356            return;
357        }
358
359        // Render the character with its actual style
360        let char_style = Style::default()
361            .fg(self.preview.fg)
362            .bg(self.preview.bg)
363            .add_modifier(self.preview.modifier);
364
365        // Format RGB values compactly
366        let fg_str = format_color_compact(self.preview.fg);
367        let bg_str = format_color_compact(self.preview.bg);
368        let mod_str = format_modifier_compact(self.preview.modifier);
369
370        // Character background highlight
371        let char_bg = Style::default().bg(DebugStyle::bg_surface());
372        let mod_style = Style::default().fg(DebugStyle::neon_purple());
373
374        // Single line: [char]  fg █ RGB  bg █ RGB  mod
375        let mut spans = vec![
376            Span::styled(" ", char_bg),
377            Span::styled(self.preview.symbol.clone(), char_style),
378            Span::styled(" ", char_bg),
379            Span::styled("  fg ", self.label_style),
380            Span::styled("█", Style::default().fg(self.preview.fg)),
381            Span::styled(format!(" {fg_str}"), self.value_style),
382            Span::styled("  bg ", self.label_style),
383            Span::styled("█", Style::default().fg(self.preview.bg)),
384            Span::styled(format!(" {bg_str}"), self.value_style),
385        ];
386
387        if !mod_str.is_empty() {
388            spans.push(Span::styled(format!("  {mod_str}"), mod_style));
389        }
390
391        let line = Paragraph::new(Line::from(spans));
392        line.render(area, buf);
393    }
394}
395
396// ============================================================================
397// Action Log Widget
398// ============================================================================
399
400/// Style configuration for action log rendering
401#[derive(Clone)]
402pub struct ActionLogStyle {
403    /// Style for the header row
404    pub header: Style,
405    /// Style for sequence numbers
406    pub sequence: Style,
407    /// Style for action names
408    pub name: Style,
409    /// Style for summaries
410    pub summary: Style,
411    /// Style for elapsed time
412    pub elapsed: Style,
413    /// Style for state_changed = true
414    pub changed_yes: Style,
415    /// Style for state_changed = false
416    pub changed_no: Style,
417    /// Selected row style
418    pub selected: Style,
419    /// Alternating row styles (even, odd)
420    pub row_styles: (Style, Style),
421}
422
423impl Default for ActionLogStyle {
424    fn default() -> Self {
425        use super::config::DebugStyle;
426        Self {
427            header: Style::default()
428                .fg(DebugStyle::neon_cyan())
429                .add_modifier(Modifier::BOLD),
430            sequence: Style::default().fg(DebugStyle::text_secondary()),
431            name: Style::default()
432                .fg(DebugStyle::neon_amber())
433                .add_modifier(Modifier::BOLD),
434            summary: Style::default().fg(DebugStyle::text_primary()),
435            elapsed: Style::default().fg(DebugStyle::text_secondary()),
436            changed_yes: Style::default().fg(DebugStyle::neon_green()),
437            changed_no: Style::default().fg(DebugStyle::text_secondary()),
438            selected: Style::default()
439                .bg(DebugStyle::bg_highlight())
440                .add_modifier(Modifier::BOLD),
441            row_styles: (
442                Style::default().bg(DebugStyle::bg_panel()),
443                Style::default().bg(DebugStyle::bg_surface()),
444            ),
445        }
446    }
447}
448
449/// A widget for rendering an action log overlay
450///
451/// Displays recent actions in a scrollable table format with:
452/// - Sequence number
453/// - Action name
454/// - Summary (truncated if necessary)
455/// - Elapsed time since action
456/// - State change indicator
457pub struct ActionLogWidget<'a> {
458    log: &'a ActionLogOverlay,
459    style: ActionLogStyle,
460    /// Number of visible rows (for scroll calculations)
461    visible_rows: usize,
462}
463
464impl<'a> ActionLogWidget<'a> {
465    /// Create a new action log widget
466    pub fn new(log: &'a ActionLogOverlay) -> Self {
467        Self {
468            log,
469            style: ActionLogStyle::default(),
470            visible_rows: 10, // Default, will be adjusted based on area
471        }
472    }
473
474    /// Set the style configuration
475    pub fn style(mut self, style: ActionLogStyle) -> Self {
476        self.style = style;
477        self
478    }
479
480    /// Set the number of visible rows
481    pub fn visible_rows(mut self, rows: usize) -> Self {
482        self.visible_rows = rows;
483        self
484    }
485}
486
487impl Widget for ActionLogWidget<'_> {
488    fn render(self, area: Rect, buf: &mut Buffer) {
489        if area.height < 2 || area.width < 30 {
490            return;
491        }
492
493        // Reserve 1 row for header
494        let visible_rows = (area.height.saturating_sub(1)) as usize;
495
496        // Column layout: [#] [Action] [Summary] [Elapsed] [Chg]
497        let constraints = [
498            Constraint::Length(5),  // Sequence #
499            Constraint::Length(24), // Action name
500            Constraint::Min(20),    // Summary (flexible)
501            Constraint::Length(8),  // Elapsed
502            Constraint::Length(3),  // Changed indicator
503        ];
504
505        // Header row
506        let header = Row::new(vec![
507            Cell::from("#").style(self.style.header),
508            Cell::from("Action").style(self.style.header),
509            Cell::from("Summary").style(self.style.header),
510            Cell::from("Elapsed").style(self.style.header),
511            Cell::from("Chg").style(self.style.header),
512        ]);
513
514        // Calculate scroll offset to keep selected row visible
515        let scroll_offset = if self.log.selected >= visible_rows {
516            self.log.selected - visible_rows + 1
517        } else {
518            0
519        };
520
521        // Build visible rows
522        let rows: Vec<Row> = self
523            .log
524            .entries
525            .iter()
526            .enumerate()
527            .skip(scroll_offset)
528            .take(visible_rows)
529            .map(|(idx, entry)| {
530                let is_selected = idx == self.log.selected;
531                let base_style = if is_selected {
532                    self.style.selected
533                } else if idx % 2 == 0 {
534                    self.style.row_styles.0
535                } else {
536                    self.style.row_styles.1
537                };
538
539                let changed_cell = match entry.state_changed {
540                    Some(true) => Cell::from("Y").style(self.style.changed_yes),
541                    Some(false) => Cell::from("N").style(self.style.changed_no),
542                    None => Cell::from("-").style(self.style.elapsed),
543                };
544
545                // Truncate summary if needed (char-aware to avoid UTF-8 panic)
546                let summary = if entry.summary.chars().count() > 60 {
547                    let truncated: String = entry.summary.chars().take(57).collect();
548                    format!("{}...", truncated)
549                } else {
550                    entry.summary.clone()
551                };
552
553                Row::new(vec![
554                    Cell::from(format!("{}", entry.sequence)).style(self.style.sequence),
555                    Cell::from(entry.name.clone()).style(self.style.name),
556                    Cell::from(summary).style(self.style.summary),
557                    Cell::from(entry.elapsed.clone()).style(self.style.elapsed),
558                    changed_cell,
559                ])
560                .style(base_style)
561            })
562            .collect();
563
564        let table = Table::new(rows, constraints)
565            .header(header)
566            .column_spacing(1);
567
568        table.render(area, buf);
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575    use ratatui::layout::Rect;
576
577    #[test]
578    fn test_buffer_to_text() {
579        let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 3));
580
581        // Write some text
582        buffer[(0, 0)].set_char('H');
583        buffer[(1, 0)].set_char('i');
584        buffer[(0, 1)].set_char('!');
585
586        let text = buffer_to_text(&buffer);
587        let lines: Vec<&str> = text.lines().collect();
588
589        assert_eq!(lines[0], "Hi");
590        assert_eq!(lines[1], "!");
591    }
592
593    #[test]
594    fn test_debug_banner() {
595        let banner =
596            DebugBanner::new()
597                .title("TEST")
598                .item(BannerItem::new("F1", "help", Style::default()));
599
600        let mut buffer = Buffer::empty(Rect::new(0, 0, 40, 1));
601        banner.render(Rect::new(0, 0, 40, 1), &mut buffer);
602
603        let text = buffer_to_text(&buffer);
604        assert!(text.contains("TEST"));
605        assert!(text.contains("F1"));
606        assert!(text.contains("help"));
607    }
608}