1use ratatui::buffer::Buffer;
7use ratatui::layout::{Constraint, Rect};
8use ratatui::style::{Modifier, Style};
9use ratatui::text::{Line, Span, Text};
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::config::DebugStyle;
15use super::table::{ActionLogOverlay, DebugTableOverlay, DebugTableRow};
16
17pub fn buffer_to_text(buffer: &Buffer) -> String {
21 let area = buffer.area;
22 let mut out = String::new();
23
24 for y in area.y..area.y.saturating_add(area.height) {
25 let mut line = String::new();
26 for x in area.x..area.x.saturating_add(area.width) {
27 line.push_str(buffer[(x, y)].symbol());
28 }
29 out.push_str(line.trim_end_matches(' '));
30 if y + 1 < area.y.saturating_add(area.height) {
31 out.push('\n');
32 }
33 }
34
35 out
36}
37
38pub fn paint_snapshot(f: &mut Frame, snapshot: &Buffer) {
42 let screen = f.area();
43 f.render_widget(Clear, screen);
44
45 let snap_area = snapshot.area;
46 let x_end = screen
47 .x
48 .saturating_add(screen.width)
49 .min(snap_area.x.saturating_add(snap_area.width));
50 let y_end = screen
51 .y
52 .saturating_add(screen.height)
53 .min(snap_area.y.saturating_add(snap_area.height));
54
55 for y in screen.y..y_end {
56 for x in screen.x..x_end {
57 f.buffer_mut()[(x, y)] = snapshot[(x, y)].clone();
58 }
59 }
60}
61
62pub fn dim_buffer(buffer: &mut Buffer, factor: f32) {
68 let factor = factor.clamp(0.0, 1.0);
69 let scale = 1.0 - factor; for cell in buffer.content.iter_mut() {
72 if contains_emoji(cell.symbol()) {
74 cell.set_symbol(" ");
75 }
76 cell.fg = dim_color(cell.fg, scale);
77 cell.bg = dim_color(cell.bg, scale);
78 }
79}
80
81fn contains_emoji(s: &str) -> bool {
83 for c in s.chars() {
84 if is_emoji(c) {
85 return true;
86 }
87 }
88 false
89}
90
91fn is_emoji(c: char) -> bool {
97 let cp = c as u32;
98 matches!(cp,
100 0x1F300..=0x1F5FF |
102 0x1F600..=0x1F64F |
104 0x1F680..=0x1F6FF |
106 0x1F900..=0x1F9FF |
108 0x1FA00..=0x1FA6F |
110 0x1FA70..=0x1FAFF |
112 0x1F1E0..=0x1F1FF
114 )
115}
116
117fn dim_color(color: ratatui::style::Color, scale: f32) -> ratatui::style::Color {
119 use ratatui::style::Color;
120
121 match color {
122 Color::Rgb(r, g, b) => Color::Rgb(
123 ((r as f32) * scale) as u8,
124 ((g as f32) * scale) as u8,
125 ((b as f32) * scale) as u8,
126 ),
127 Color::Indexed(idx) => {
128 if let Some((r, g, b)) = indexed_to_rgb(idx) {
131 Color::Rgb(
132 ((r as f32) * scale) as u8,
133 ((g as f32) * scale) as u8,
134 ((b as f32) * scale) as u8,
135 )
136 } else {
137 color }
139 }
140 Color::Black => Color::Black,
142 Color::Red => dim_named_color(205, 0, 0, scale),
143 Color::Green => dim_named_color(0, 205, 0, scale),
144 Color::Yellow => dim_named_color(205, 205, 0, scale),
145 Color::Blue => dim_named_color(0, 0, 238, scale),
146 Color::Magenta => dim_named_color(205, 0, 205, scale),
147 Color::Cyan => dim_named_color(0, 205, 205, scale),
148 Color::Gray => dim_named_color(229, 229, 229, scale),
149 Color::DarkGray => dim_named_color(127, 127, 127, scale),
150 Color::LightRed => dim_named_color(255, 0, 0, scale),
151 Color::LightGreen => dim_named_color(0, 255, 0, scale),
152 Color::LightYellow => dim_named_color(255, 255, 0, scale),
153 Color::LightBlue => dim_named_color(92, 92, 255, scale),
154 Color::LightMagenta => dim_named_color(255, 0, 255, scale),
155 Color::LightCyan => dim_named_color(0, 255, 255, scale),
156 Color::White => dim_named_color(255, 255, 255, scale),
157 Color::Reset => Color::Reset,
158 }
159}
160
161fn dim_named_color(r: u8, g: u8, b: u8, scale: f32) -> ratatui::style::Color {
162 ratatui::style::Color::Rgb(
163 ((r as f32) * scale) as u8,
164 ((g as f32) * scale) as u8,
165 ((b as f32) * scale) as u8,
166 )
167}
168
169fn indexed_to_rgb(idx: u8) -> Option<(u8, u8, u8)> {
171 match idx {
172 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 => {
191 let idx = idx - 16;
192 let r = (idx / 36) % 6;
193 let g = (idx / 6) % 6;
194 let b = idx % 6;
195 let to_rgb = |v: u8| if v == 0 { 0 } else { 55 + v * 40 };
196 Some((to_rgb(r), to_rgb(g), to_rgb(b)))
197 }
198 232..=255 => {
200 let gray = 8 + (idx - 232) * 10;
201 Some((gray, gray, gray))
202 }
203 }
204}
205
206#[derive(Clone)]
208pub struct BannerItem<'a> {
209 pub key: &'a str,
211 pub label: &'a str,
213 pub key_style: Style,
215}
216
217impl<'a> BannerItem<'a> {
218 pub fn new(key: &'a str, label: &'a str, key_style: Style) -> Self {
220 Self {
221 key,
222 label,
223 key_style,
224 }
225 }
226}
227
228pub struct DebugBanner<'a> {
242 title: Option<&'a str>,
243 title_style: Style,
244 items: Vec<BannerItem<'a>>,
245 label_style: Style,
246 background: Style,
247}
248
249impl<'a> Default for DebugBanner<'a> {
250 fn default() -> Self {
251 Self::new()
252 }
253}
254
255impl<'a> DebugBanner<'a> {
256 pub fn new() -> Self {
258 Self {
259 title: None,
260 title_style: Style::default(),
261 items: Vec::new(),
262 label_style: Style::default(),
263 background: Style::default(),
264 }
265 }
266
267 pub fn title(mut self, title: &'a str) -> Self {
269 self.title = Some(title);
270 self
271 }
272
273 pub fn title_style(mut self, style: Style) -> Self {
275 self.title_style = style;
276 self
277 }
278
279 pub fn item(mut self, item: BannerItem<'a>) -> Self {
281 self.items.push(item);
282 self
283 }
284
285 pub fn label_style(mut self, style: Style) -> Self {
287 self.label_style = style;
288 self
289 }
290
291 pub fn background(mut self, style: Style) -> Self {
293 self.background = style;
294 self
295 }
296}
297
298impl Widget for DebugBanner<'_> {
299 fn render(self, area: Rect, buf: &mut Buffer) {
300 if area.height == 0 || area.width == 0 {
301 return;
302 }
303
304 for y in area.y..area.y.saturating_add(area.height) {
306 for x in area.x..area.x.saturating_add(area.width) {
307 if let Some(cell) = buf.cell_mut((x, y)) {
308 cell.set_symbol(" ");
309 cell.set_style(self.background);
310 }
311 }
312 }
313
314 let mut spans = Vec::new();
315
316 if let Some(title) = self.title {
318 spans.push(Span::styled(format!(" {title} "), self.title_style));
319 spans.push(Span::raw(" "));
320 }
321
322 for item in &self.items {
324 spans.push(Span::styled(format!(" {} ", item.key), item.key_style));
325 spans.push(Span::styled(format!(" {} ", item.label), self.label_style));
326 }
327
328 let line = Paragraph::new(Line::from(spans)).style(self.background);
329 line.render(area, buf);
330 }
331}
332
333#[derive(Clone)]
335pub struct DebugTableStyle {
336 pub header: Style,
338 pub section: Style,
340 pub key: Style,
342 pub value: Style,
344 pub row_styles: (Style, Style),
346}
347
348impl Default for DebugTableStyle {
349 fn default() -> Self {
350 use super::config::DebugStyle;
351 Self {
352 header: Style::default()
353 .fg(DebugStyle::accent())
354 .bg(DebugStyle::overlay_bg_dark())
355 .add_modifier(Modifier::BOLD),
356 section: Style::default()
357 .fg(DebugStyle::neon_purple())
358 .bg(DebugStyle::overlay_bg_dark())
359 .add_modifier(Modifier::BOLD),
360 key: Style::default()
361 .fg(DebugStyle::neon_amber())
362 .add_modifier(Modifier::BOLD),
363 value: Style::default().fg(DebugStyle::text_primary()),
364 row_styles: (
365 Style::default().bg(DebugStyle::overlay_bg()),
366 Style::default().bg(DebugStyle::overlay_bg_alt()),
367 ),
368 }
369 }
370}
371
372pub struct DebugTableWidget<'a> {
374 table: &'a DebugTableOverlay,
375 style: DebugTableStyle,
376 scroll_offset: usize,
377}
378
379impl<'a> DebugTableWidget<'a> {
380 pub fn new(table: &'a DebugTableOverlay) -> Self {
382 Self {
383 table,
384 style: DebugTableStyle::default(),
385 scroll_offset: 0,
386 }
387 }
388
389 pub fn style(mut self, style: DebugTableStyle) -> Self {
391 self.style = style;
392 self
393 }
394
395 pub fn scroll_offset(mut self, scroll_offset: usize) -> Self {
397 self.scroll_offset = scroll_offset;
398 self
399 }
400}
401
402impl Widget for DebugTableWidget<'_> {
403 fn render(self, area: Rect, buf: &mut Buffer) {
404 if area.height < 2 || area.width < 10 {
405 return;
406 }
407
408 let max_key_len = self
410 .table
411 .rows
412 .iter()
413 .filter_map(|row| match row {
414 DebugTableRow::Entry { key, .. } => Some(key.chars().count()),
415 DebugTableRow::Section(_) => None,
416 })
417 .max()
418 .unwrap_or(0) as u16;
419
420 let max_label = area.width.saturating_sub(8).max(10);
421 let label_width = max_key_len.saturating_add(2).clamp(12, 30).min(max_label);
422 let constraints = [Constraint::Length(label_width), Constraint::Min(0)];
423
424 let header = Row::new(vec![
426 Cell::from("Field").style(self.style.header),
427 Cell::from("Value").style(self.style.header),
428 ]);
429
430 let visible_rows = area.height.saturating_sub(1) as usize;
432 let max_offset = self.table.rows.len().saturating_sub(visible_rows);
433 let scroll_offset = self.scroll_offset.min(max_offset);
434
435 let rows: Vec<Row> = self
436 .table
437 .rows
438 .iter()
439 .skip(scroll_offset)
440 .enumerate()
441 .map(|(idx, row)| match row {
442 DebugTableRow::Section(title) => Row::new(vec![
443 Cell::from(format!(" {title} ")).style(self.style.section),
444 Cell::from(""),
445 ]),
446 DebugTableRow::Entry { key, value } => {
447 let row_index = idx + scroll_offset;
448 let row_style = if row_index % 2 == 0 {
449 self.style.row_styles.0
450 } else {
451 self.style.row_styles.1
452 };
453 let syntax_style = DebugSyntaxStyle::with_base(self.style.value);
454 let value_line = Line::from(debug_spans(value, &syntax_style));
455 Row::new(vec![
456 Cell::from(key.clone()).style(self.style.key),
457 Cell::from(value_line).style(self.style.value),
458 ])
459 .style(row_style)
460 }
461 })
462 .collect();
463
464 let table = Table::new(rows, constraints)
465 .header(header)
466 .column_spacing(2);
467
468 table.render(area, buf);
469 }
470}
471
472pub struct CellPreviewWidget<'a> {
474 preview: &'a CellPreview,
475 label_style: Style,
476 value_style: Style,
477}
478
479impl<'a> CellPreviewWidget<'a> {
480 pub fn new(preview: &'a CellPreview) -> Self {
482 use super::config::DebugStyle;
483 Self {
484 preview,
485 label_style: Style::default().fg(DebugStyle::text_secondary()),
486 value_style: Style::default().fg(DebugStyle::text_primary()),
487 }
488 }
489
490 pub fn label_style(mut self, style: Style) -> Self {
492 self.label_style = style;
493 self
494 }
495
496 pub fn value_style(mut self, style: Style) -> Self {
498 self.value_style = style;
499 self
500 }
501}
502
503impl Widget for CellPreviewWidget<'_> {
504 fn render(self, area: Rect, buf: &mut Buffer) {
505 use super::config::DebugStyle;
506
507 if area.width < 20 || area.height < 1 {
508 return;
509 }
510
511 let char_style = Style::default()
513 .fg(self.preview.fg)
514 .bg(self.preview.bg)
515 .add_modifier(self.preview.modifier);
516
517 let fg_str = format_color_compact(self.preview.fg);
519 let bg_str = format_color_compact(self.preview.bg);
520 let mod_str = format_modifier_compact(self.preview.modifier);
521
522 let char_bg = Style::default().bg(DebugStyle::bg_surface());
524 let mod_style = Style::default().fg(DebugStyle::neon_purple());
525
526 let mut spans = vec![
528 Span::styled(" ", char_bg),
529 Span::styled(self.preview.symbol.clone(), char_style),
530 Span::styled(" ", char_bg),
531 Span::styled(" fg ", self.label_style),
532 Span::styled("█", Style::default().fg(self.preview.fg)),
533 Span::styled(format!(" {fg_str}"), self.value_style),
534 Span::styled(" bg ", self.label_style),
535 Span::styled("█", Style::default().fg(self.preview.bg)),
536 Span::styled(format!(" {bg_str}"), self.value_style),
537 ];
538
539 if !mod_str.is_empty() {
540 spans.push(Span::styled(format!(" {mod_str}"), mod_style));
541 }
542
543 let line = Paragraph::new(Line::from(spans));
544 line.render(area, buf);
545 }
546}
547
548#[derive(Clone)]
553pub(crate) struct DebugSyntaxStyle {
554 punctuation: Style,
555 string: Style,
556 number: Style,
557 keyword: Style,
558 identifier: Style,
559 fallback: Style,
560}
561
562impl DebugSyntaxStyle {
563 pub(crate) fn with_base(base: Style) -> Self {
564 Self {
565 punctuation: Style::default().fg(DebugStyle::text_secondary()),
566 string: Style::default().fg(DebugStyle::neon_green()),
567 number: Style::default().fg(DebugStyle::neon_cyan()),
568 keyword: Style::default().fg(DebugStyle::neon_purple()),
569 identifier: base,
570 fallback: base,
571 }
572 }
573}
574
575pub(crate) fn debug_spans(value: &str, style: &DebugSyntaxStyle) -> Vec<Span<'static>> {
576 let mut spans: Vec<Span<'static>> = Vec::new();
577 let mut chars = value.chars().peekable();
578
579 #[allow(clippy::while_let_on_iterator)]
580 while let Some(ch) = chars.next() {
581 if ch == '"' || ch == '\'' {
582 let quote = ch;
583 let mut token = String::from(quote);
584 let mut escaped = false;
585 while let Some(next) = chars.next() {
586 token.push(next);
587 if escaped {
588 escaped = false;
589 continue;
590 }
591 if next == '\\' {
592 escaped = true;
593 continue;
594 }
595 if next == quote {
596 break;
597 }
598 }
599 spans.push(Span::styled(token, style.string));
600 continue;
601 }
602
603 if is_debug_punctuation(ch) {
604 spans.push(Span::styled(ch.to_string(), style.punctuation));
605 continue;
606 }
607
608 if ch.is_whitespace() {
609 spans.push(Span::styled(ch.to_string(), style.fallback));
610 continue;
611 }
612
613 if is_number_start(ch, chars.peek()) {
614 let mut token = String::new();
615 token.push(ch);
616 while let Some(&next) = chars.peek() {
617 if is_number_char(next) {
618 token.push(next);
619 chars.next();
620 } else {
621 break;
622 }
623 }
624 spans.push(Span::styled(token, style.number));
625 continue;
626 }
627
628 if is_ident_start(ch) {
629 let mut token = String::new();
630 token.push(ch);
631 while let Some(&next) = chars.peek() {
632 if is_ident_char(next) {
633 token.push(next);
634 chars.next();
635 } else {
636 break;
637 }
638 }
639 let token_style = if is_debug_keyword(&token) {
640 style.keyword
641 } else {
642 style.identifier
643 };
644 spans.push(Span::styled(token, token_style));
645 continue;
646 }
647
648 spans.push(Span::styled(ch.to_string(), style.fallback));
649 }
650
651 spans
652}
653
654fn is_debug_punctuation(ch: char) -> bool {
655 matches!(ch, '{' | '}' | '[' | ']' | '(' | ')' | ':' | ',' | '=')
656}
657
658fn is_number_start(ch: char, next: Option<&char>) -> bool {
659 if ch.is_ascii_digit() {
660 return true;
661 }
662 if ch == '-' {
663 return next.map(|c| c.is_ascii_digit()).unwrap_or(false);
664 }
665 false
666}
667
668fn is_number_char(ch: char) -> bool {
669 ch.is_ascii_digit() || matches!(ch, '.' | '_' | '+' | '-' | 'e' | 'E')
670}
671
672fn is_ident_start(ch: char) -> bool {
673 ch.is_ascii_alphabetic() || ch == '_'
674}
675
676fn is_ident_char(ch: char) -> bool {
677 ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'
678}
679
680fn is_debug_keyword(token: &str) -> bool {
681 matches!(token, "true" | "false" | "None" | "Some" | "null")
682}
683
684#[derive(Clone)]
690pub struct ActionLogStyle {
691 pub header: Style,
693 pub sequence: Style,
695 pub name: Style,
697 pub params: Style,
699 pub elapsed: Style,
701 pub selected: Style,
703 pub row_styles: (Style, Style),
705}
706
707impl Default for ActionLogStyle {
708 fn default() -> Self {
709 use super::config::DebugStyle;
710 Self {
711 header: Style::default()
712 .fg(DebugStyle::accent())
713 .bg(DebugStyle::overlay_bg_dark())
714 .add_modifier(Modifier::BOLD),
715 sequence: Style::default().fg(DebugStyle::text_secondary()),
716 name: Style::default()
717 .fg(DebugStyle::neon_amber())
718 .add_modifier(Modifier::BOLD),
719 params: Style::default().fg(DebugStyle::text_primary()),
720 elapsed: Style::default().fg(DebugStyle::text_secondary()),
721 selected: Style::default()
722 .bg(DebugStyle::bg_highlight())
723 .add_modifier(Modifier::BOLD),
724 row_styles: (
725 Style::default().bg(DebugStyle::overlay_bg()),
726 Style::default().bg(DebugStyle::overlay_bg_alt()),
727 ),
728 }
729 }
730}
731
732pub struct ActionLogWidget<'a> {
740 log: &'a ActionLogOverlay,
741 style: ActionLogStyle,
742 visible_rows: usize,
744}
745
746impl<'a> ActionLogWidget<'a> {
747 pub fn new(log: &'a ActionLogOverlay) -> Self {
749 Self {
750 log,
751 style: ActionLogStyle::default(),
752 visible_rows: 10, }
754 }
755
756 pub fn style(mut self, style: ActionLogStyle) -> Self {
758 self.style = style;
759 self
760 }
761
762 pub fn visible_rows(mut self, rows: usize) -> Self {
764 self.visible_rows = rows;
765 self
766 }
767}
768
769impl Widget for ActionLogWidget<'_> {
770 fn render(self, area: Rect, buf: &mut Buffer) {
771 if area.height < 2 || area.width < 30 {
772 return;
773 }
774
775 let visible_rows = (area.height.saturating_sub(1)) as usize;
777
778 let constraints = [
780 Constraint::Length(5), Constraint::Length(20), Constraint::Min(30), Constraint::Length(8), ];
785
786 let header = Row::new(vec![
788 Cell::from("#").style(self.style.header),
789 Cell::from("Action").style(self.style.header),
790 Cell::from("Params").style(self.style.header),
791 Cell::from("Elapsed").style(self.style.header),
792 ]);
793
794 let scroll_offset = self.log.scroll_offset_for(visible_rows);
796
797 let rows: Vec<Row> = self
799 .log
800 .entries
801 .iter()
802 .enumerate()
803 .skip(scroll_offset)
804 .take(visible_rows)
805 .map(|(idx, entry)| {
806 let is_selected = idx == self.log.selected;
807 let base_style = if is_selected {
808 self.style.selected
809 } else if idx % 2 == 0 {
810 self.style.row_styles.0
811 } else {
812 self.style.row_styles.1
813 };
814
815 let params_compact = entry.params.replace('\n', " ");
816 let params_compact = params_compact
817 .split_whitespace()
818 .collect::<Vec<_>>()
819 .join(" ");
820
821 let params = if params_compact.chars().count() > 60 {
823 let truncated: String = params_compact.chars().take(57).collect();
824 format!("{}...", truncated)
825 } else {
826 params_compact
827 };
828 let syntax_style = DebugSyntaxStyle::with_base(self.style.params);
829 let params_line = Line::from(debug_spans(¶ms, &syntax_style));
830
831 Row::new(vec![
832 Cell::from(format!("{}", entry.sequence)).style(self.style.sequence),
833 Cell::from(entry.name.clone()).style(self.style.name),
834 Cell::from(Text::from(params_line)).style(self.style.params),
835 Cell::from(entry.elapsed.clone()).style(self.style.elapsed),
836 ])
837 .style(base_style)
838 })
839 .collect();
840
841 let table = Table::new(rows, constraints)
842 .header(header)
843 .column_spacing(1);
844
845 table.render(area, buf);
846 }
847}
848
849#[cfg(test)]
850mod tests {
851 use super::*;
852 use ratatui::layout::Rect;
853
854 #[test]
855 fn test_buffer_to_text() {
856 let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 3));
857
858 buffer[(0, 0)].set_char('H');
860 buffer[(1, 0)].set_char('i');
861 buffer[(0, 1)].set_char('!');
862
863 let text = buffer_to_text(&buffer);
864 let lines: Vec<&str> = text.lines().collect();
865
866 assert_eq!(lines[0], "Hi");
867 assert_eq!(lines[1], "!");
868 }
869
870 #[test]
871 fn test_debug_banner() {
872 let banner =
873 DebugBanner::new()
874 .title("TEST")
875 .item(BannerItem::new("F1", "help", Style::default()));
876
877 let mut buffer = Buffer::empty(Rect::new(0, 0, 40, 1));
878 banner.render(Rect::new(0, 0, 40, 1), &mut buffer);
879
880 let text = buffer_to_text(&buffer);
881 assert!(text.contains("TEST"));
882 assert!(text.contains("F1"));
883 assert!(text.contains("help"));
884 }
885}