1use ratatui::buffer::Buffer;
7use ratatui::layout::{Constraint, Rect};
8use ratatui::style::{Modifier, Style};
9use ratatui::text::{Line, Span};
10use ratatui::widgets::{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
16pub 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
37pub 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
61pub fn dim_buffer(buffer: &mut Buffer, factor: f32) {
67 let factor = factor.clamp(0.0, 1.0);
68 let scale = 1.0 - factor; for cell in buffer.content.iter_mut() {
71 if contains_emoji(cell.symbol()) {
73 cell.set_symbol(" ");
74 }
75 cell.fg = dim_color(cell.fg, scale);
76 cell.bg = dim_color(cell.bg, scale);
77 }
78}
79
80fn contains_emoji(s: &str) -> bool {
82 for c in s.chars() {
83 if is_emoji(c) {
84 return true;
85 }
86 }
87 false
88}
89
90fn is_emoji(c: char) -> bool {
96 let cp = c as u32;
97 matches!(cp,
99 0x1F300..=0x1F5FF |
101 0x1F600..=0x1F64F |
103 0x1F680..=0x1F6FF |
105 0x1F900..=0x1F9FF |
107 0x1FA00..=0x1FA6F |
109 0x1FA70..=0x1FAFF |
111 0x1F1E0..=0x1F1FF
113 )
114}
115
116fn dim_color(color: ratatui::style::Color, scale: f32) -> ratatui::style::Color {
118 use ratatui::style::Color;
119
120 match color {
121 Color::Rgb(r, g, b) => Color::Rgb(
122 ((r as f32) * scale) as u8,
123 ((g as f32) * scale) as u8,
124 ((b as f32) * scale) as u8,
125 ),
126 Color::Indexed(idx) => {
127 if let Some((r, g, b)) = indexed_to_rgb(idx) {
130 Color::Rgb(
131 ((r as f32) * scale) as u8,
132 ((g as f32) * scale) as u8,
133 ((b as f32) * scale) as u8,
134 )
135 } else {
136 color }
138 }
139 Color::Black => Color::Black,
141 Color::Red => dim_named_color(205, 0, 0, scale),
142 Color::Green => dim_named_color(0, 205, 0, scale),
143 Color::Yellow => dim_named_color(205, 205, 0, scale),
144 Color::Blue => dim_named_color(0, 0, 238, scale),
145 Color::Magenta => dim_named_color(205, 0, 205, scale),
146 Color::Cyan => dim_named_color(0, 205, 205, scale),
147 Color::Gray => dim_named_color(229, 229, 229, scale),
148 Color::DarkGray => dim_named_color(127, 127, 127, scale),
149 Color::LightRed => dim_named_color(255, 0, 0, scale),
150 Color::LightGreen => dim_named_color(0, 255, 0, scale),
151 Color::LightYellow => dim_named_color(255, 255, 0, scale),
152 Color::LightBlue => dim_named_color(92, 92, 255, scale),
153 Color::LightMagenta => dim_named_color(255, 0, 255, scale),
154 Color::LightCyan => dim_named_color(0, 255, 255, scale),
155 Color::White => dim_named_color(255, 255, 255, scale),
156 Color::Reset => Color::Reset,
157 }
158}
159
160fn dim_named_color(r: u8, g: u8, b: u8, scale: f32) -> ratatui::style::Color {
161 ratatui::style::Color::Rgb(
162 ((r as f32) * scale) as u8,
163 ((g as f32) * scale) as u8,
164 ((b as f32) * scale) as u8,
165 )
166}
167
168fn indexed_to_rgb(idx: u8) -> Option<(u8, u8, u8)> {
170 match idx {
171 0 => Some((0, 0, 0)), 1 => Some((128, 0, 0)), 2 => Some((0, 128, 0)), 3 => Some((128, 128, 0)), 4 => Some((0, 0, 128)), 5 => Some((128, 0, 128)), 6 => Some((0, 128, 128)), 7 => Some((192, 192, 192)), 8 => Some((128, 128, 128)), 9 => Some((255, 0, 0)), 10 => Some((0, 255, 0)), 11 => Some((255, 255, 0)), 12 => Some((0, 0, 255)), 13 => Some((255, 0, 255)), 14 => Some((0, 255, 255)), 15 => Some((255, 255, 255)), 16..=231 => {
190 let idx = idx - 16;
191 let r = (idx / 36) % 6;
192 let g = (idx / 6) % 6;
193 let b = idx % 6;
194 let to_rgb = |v: u8| if v == 0 { 0 } else { 55 + v * 40 };
195 Some((to_rgb(r), to_rgb(g), to_rgb(b)))
196 }
197 232..=255 => {
199 let gray = 8 + (idx - 232) * 10;
200 Some((gray, gray, gray))
201 }
202 }
203}
204
205#[derive(Clone)]
207pub struct BannerItem<'a> {
208 pub key: &'a str,
210 pub label: &'a str,
212 pub key_style: Style,
214}
215
216impl<'a> BannerItem<'a> {
217 pub fn new(key: &'a str, label: &'a str, key_style: Style) -> Self {
219 Self {
220 key,
221 label,
222 key_style,
223 }
224 }
225}
226
227pub struct DebugBanner<'a> {
241 title: Option<&'a str>,
242 title_style: Style,
243 items: Vec<BannerItem<'a>>,
244 label_style: Style,
245 background: Style,
246}
247
248impl<'a> Default for DebugBanner<'a> {
249 fn default() -> Self {
250 Self::new()
251 }
252}
253
254impl<'a> DebugBanner<'a> {
255 pub fn new() -> Self {
257 Self {
258 title: None,
259 title_style: Style::default(),
260 items: Vec::new(),
261 label_style: Style::default(),
262 background: Style::default(),
263 }
264 }
265
266 pub fn title(mut self, title: &'a str) -> Self {
268 self.title = Some(title);
269 self
270 }
271
272 pub fn title_style(mut self, style: Style) -> Self {
274 self.title_style = style;
275 self
276 }
277
278 pub fn item(mut self, item: BannerItem<'a>) -> Self {
280 self.items.push(item);
281 self
282 }
283
284 pub fn label_style(mut self, style: Style) -> Self {
286 self.label_style = style;
287 self
288 }
289
290 pub fn background(mut self, style: Style) -> Self {
292 self.background = style;
293 self
294 }
295}
296
297impl Widget for DebugBanner<'_> {
298 fn render(self, area: Rect, buf: &mut Buffer) {
299 if area.height == 0 || area.width == 0 {
300 return;
301 }
302
303 for y in area.y..area.y.saturating_add(area.height) {
305 for x in area.x..area.x.saturating_add(area.width) {
306 if let Some(cell) = buf.cell_mut((x, y)) {
307 cell.set_symbol(" ");
308 cell.set_style(self.background);
309 }
310 }
311 }
312
313 let mut spans = Vec::new();
314
315 if let Some(title) = self.title {
317 spans.push(Span::styled(format!(" {title} "), self.title_style));
318 spans.push(Span::raw(" "));
319 }
320
321 for item in &self.items {
323 spans.push(Span::styled(format!(" {} ", item.key), item.key_style));
324 spans.push(Span::styled(format!(" {} ", item.label), self.label_style));
325 }
326
327 let line = Paragraph::new(Line::from(spans)).style(self.background);
328 line.render(area, buf);
329 }
330}
331
332#[derive(Clone)]
334pub struct DebugTableStyle {
335 pub header: Style,
337 pub section: Style,
339 pub key: Style,
341 pub value: Style,
343 pub row_styles: (Style, Style),
345}
346
347impl Default for DebugTableStyle {
348 fn default() -> Self {
349 use super::config::DebugStyle;
350 Self {
351 header: Style::default()
352 .fg(DebugStyle::neon_cyan())
353 .add_modifier(Modifier::BOLD),
354 section: Style::default()
355 .fg(DebugStyle::neon_purple())
356 .add_modifier(Modifier::BOLD),
357 key: Style::default()
358 .fg(DebugStyle::neon_amber())
359 .add_modifier(Modifier::BOLD),
360 value: Style::default().fg(DebugStyle::text_primary()),
361 row_styles: (
362 Style::default().bg(DebugStyle::bg_panel()),
363 Style::default().bg(DebugStyle::bg_surface()),
364 ),
365 }
366 }
367}
368
369pub struct DebugTableWidget<'a> {
371 table: &'a DebugTableOverlay,
372 style: DebugTableStyle,
373 scroll_offset: usize,
374}
375
376impl<'a> DebugTableWidget<'a> {
377 pub fn new(table: &'a DebugTableOverlay) -> Self {
379 Self {
380 table,
381 style: DebugTableStyle::default(),
382 scroll_offset: 0,
383 }
384 }
385
386 pub fn style(mut self, style: DebugTableStyle) -> Self {
388 self.style = style;
389 self
390 }
391
392 pub fn scroll_offset(mut self, scroll_offset: usize) -> Self {
394 self.scroll_offset = scroll_offset;
395 self
396 }
397}
398
399impl Widget for DebugTableWidget<'_> {
400 fn render(self, area: Rect, buf: &mut Buffer) {
401 if area.height < 2 || area.width < 10 {
402 return;
403 }
404
405 let max_key_len = self
407 .table
408 .rows
409 .iter()
410 .filter_map(|row| match row {
411 DebugTableRow::Entry { key, .. } => Some(key.chars().count()),
412 DebugTableRow::Section(_) => None,
413 })
414 .max()
415 .unwrap_or(0) as u16;
416
417 let max_label = area.width.saturating_sub(8).max(10);
418 let label_width = max_key_len.saturating_add(2).clamp(12, 30).min(max_label);
419 let constraints = [Constraint::Length(label_width), Constraint::Min(0)];
420
421 let header = Row::new(vec![
423 Cell::from("Field").style(self.style.header),
424 Cell::from("Value").style(self.style.header),
425 ]);
426
427 let visible_rows = area.height.saturating_sub(1) as usize;
429 let max_offset = self.table.rows.len().saturating_sub(visible_rows);
430 let scroll_offset = self.scroll_offset.min(max_offset);
431
432 let rows: Vec<Row> = self
433 .table
434 .rows
435 .iter()
436 .skip(scroll_offset)
437 .enumerate()
438 .map(|(idx, row)| match row {
439 DebugTableRow::Section(title) => Row::new(vec![
440 Cell::from(format!(" {title} ")).style(self.style.section),
441 Cell::from(""),
442 ]),
443 DebugTableRow::Entry { key, value } => {
444 let row_index = idx + scroll_offset;
445 let row_style = if row_index % 2 == 0 {
446 self.style.row_styles.0
447 } else {
448 self.style.row_styles.1
449 };
450 Row::new(vec![
451 Cell::from(key.clone()).style(self.style.key),
452 Cell::from(value.clone()).style(self.style.value),
453 ])
454 .style(row_style)
455 }
456 })
457 .collect();
458
459 let table = Table::new(rows, constraints)
460 .header(header)
461 .column_spacing(2);
462
463 table.render(area, buf);
464 }
465}
466
467pub struct CellPreviewWidget<'a> {
469 preview: &'a CellPreview,
470 label_style: Style,
471 value_style: Style,
472}
473
474impl<'a> CellPreviewWidget<'a> {
475 pub fn new(preview: &'a CellPreview) -> Self {
477 use super::config::DebugStyle;
478 Self {
479 preview,
480 label_style: Style::default().fg(DebugStyle::text_secondary()),
481 value_style: Style::default().fg(DebugStyle::text_primary()),
482 }
483 }
484
485 pub fn label_style(mut self, style: Style) -> Self {
487 self.label_style = style;
488 self
489 }
490
491 pub fn value_style(mut self, style: Style) -> Self {
493 self.value_style = style;
494 self
495 }
496}
497
498impl Widget for CellPreviewWidget<'_> {
499 fn render(self, area: Rect, buf: &mut Buffer) {
500 use super::config::DebugStyle;
501
502 if area.width < 20 || area.height < 1 {
503 return;
504 }
505
506 let char_style = Style::default()
508 .fg(self.preview.fg)
509 .bg(self.preview.bg)
510 .add_modifier(self.preview.modifier);
511
512 let fg_str = format_color_compact(self.preview.fg);
514 let bg_str = format_color_compact(self.preview.bg);
515 let mod_str = format_modifier_compact(self.preview.modifier);
516
517 let char_bg = Style::default().bg(DebugStyle::bg_surface());
519 let mod_style = Style::default().fg(DebugStyle::neon_purple());
520
521 let mut spans = vec![
523 Span::styled(" ", char_bg),
524 Span::styled(self.preview.symbol.clone(), char_style),
525 Span::styled(" ", char_bg),
526 Span::styled(" fg ", self.label_style),
527 Span::styled("█", Style::default().fg(self.preview.fg)),
528 Span::styled(format!(" {fg_str}"), self.value_style),
529 Span::styled(" bg ", self.label_style),
530 Span::styled("█", Style::default().fg(self.preview.bg)),
531 Span::styled(format!(" {bg_str}"), self.value_style),
532 ];
533
534 if !mod_str.is_empty() {
535 spans.push(Span::styled(format!(" {mod_str}"), mod_style));
536 }
537
538 let line = Paragraph::new(Line::from(spans));
539 line.render(area, buf);
540 }
541}
542
543#[derive(Clone)]
549pub struct ActionLogStyle {
550 pub header: Style,
552 pub sequence: Style,
554 pub name: Style,
556 pub params: Style,
558 pub elapsed: Style,
560 pub selected: Style,
562 pub row_styles: (Style, Style),
564}
565
566impl Default for ActionLogStyle {
567 fn default() -> Self {
568 use super::config::DebugStyle;
569 Self {
570 header: Style::default()
571 .fg(DebugStyle::neon_cyan())
572 .add_modifier(Modifier::BOLD),
573 sequence: Style::default().fg(DebugStyle::text_secondary()),
574 name: Style::default()
575 .fg(DebugStyle::neon_amber())
576 .add_modifier(Modifier::BOLD),
577 params: Style::default().fg(DebugStyle::text_primary()),
578 elapsed: Style::default().fg(DebugStyle::text_secondary()),
579 selected: Style::default()
580 .bg(DebugStyle::bg_highlight())
581 .add_modifier(Modifier::BOLD),
582 row_styles: (
583 Style::default().bg(DebugStyle::bg_panel()),
584 Style::default().bg(DebugStyle::bg_surface()),
585 ),
586 }
587 }
588}
589
590pub struct ActionLogWidget<'a> {
598 log: &'a ActionLogOverlay,
599 style: ActionLogStyle,
600 visible_rows: usize,
602}
603
604impl<'a> ActionLogWidget<'a> {
605 pub fn new(log: &'a ActionLogOverlay) -> Self {
607 Self {
608 log,
609 style: ActionLogStyle::default(),
610 visible_rows: 10, }
612 }
613
614 pub fn style(mut self, style: ActionLogStyle) -> Self {
616 self.style = style;
617 self
618 }
619
620 pub fn visible_rows(mut self, rows: usize) -> Self {
622 self.visible_rows = rows;
623 self
624 }
625}
626
627impl Widget for ActionLogWidget<'_> {
628 fn render(self, area: Rect, buf: &mut Buffer) {
629 if area.height < 2 || area.width < 30 {
630 return;
631 }
632
633 let visible_rows = (area.height.saturating_sub(1)) as usize;
635
636 let constraints = [
638 Constraint::Length(5), Constraint::Length(20), Constraint::Min(30), Constraint::Length(8), ];
643
644 let header = Row::new(vec![
646 Cell::from("#").style(self.style.header),
647 Cell::from("Action").style(self.style.header),
648 Cell::from("Params").style(self.style.header),
649 Cell::from("Elapsed").style(self.style.header),
650 ]);
651
652 let scroll_offset = self.log.scroll_offset_for(visible_rows);
654
655 let rows: Vec<Row> = self
657 .log
658 .entries
659 .iter()
660 .enumerate()
661 .skip(scroll_offset)
662 .take(visible_rows)
663 .map(|(idx, entry)| {
664 let is_selected = idx == self.log.selected;
665 let base_style = if is_selected {
666 self.style.selected
667 } else if idx % 2 == 0 {
668 self.style.row_styles.0
669 } else {
670 self.style.row_styles.1
671 };
672
673 let params = if entry.params.chars().count() > 60 {
675 let truncated: String = entry.params.chars().take(57).collect();
676 format!("{}...", truncated)
677 } else {
678 entry.params.clone()
679 };
680
681 Row::new(vec![
682 Cell::from(format!("{}", entry.sequence)).style(self.style.sequence),
683 Cell::from(entry.name.clone()).style(self.style.name),
684 Cell::from(params).style(self.style.params),
685 Cell::from(entry.elapsed.clone()).style(self.style.elapsed),
686 ])
687 .style(base_style)
688 })
689 .collect();
690
691 let table = Table::new(rows, constraints)
692 .header(header)
693 .column_spacing(1);
694
695 table.render(area, buf);
696 }
697}
698
699#[cfg(test)]
700mod tests {
701 use super::*;
702 use ratatui::layout::Rect;
703
704 #[test]
705 fn test_buffer_to_text() {
706 let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 3));
707
708 buffer[(0, 0)].set_char('H');
710 buffer[(1, 0)].set_char('i');
711 buffer[(0, 1)].set_char('!');
712
713 let text = buffer_to_text(&buffer);
714 let lines: Vec<&str> = text.lines().collect();
715
716 assert_eq!(lines[0], "Hi");
717 assert_eq!(lines[1], "!");
718 }
719
720 #[test]
721 fn test_debug_banner() {
722 let banner =
723 DebugBanner::new()
724 .title("TEST")
725 .item(BannerItem::new("F1", "help", Style::default()));
726
727 let mut buffer = Buffer::empty(Rect::new(0, 0, 40, 1));
728 banner.render(Rect::new(0, 0, 40, 1), &mut buffer);
729
730 let text = buffer_to_text(&buffer);
731 assert!(text.contains("TEST"));
732 assert!(text.contains("F1"));
733 assert!(text.contains("help"));
734 }
735}