Skip to main content

rgx/ui/
match_display.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::Rect,
4    style::{Modifier, Style},
5    text::{Line, Span},
6    widgets::{Block, Borders, Paragraph, Widget, Wrap},
7};
8
9use crate::engine;
10use crate::ui::test_input::split_spans_into_lines;
11use crate::ui::theme;
12
13pub struct MatchDisplay<'a> {
14    pub matches: &'a [engine::Match],
15    pub replace_result: Option<&'a engine::ReplaceResult>,
16    pub scroll: u16,
17    pub focused: bool,
18    pub selected_match: usize,
19    pub selected_capture: Option<usize>,
20    pub clipboard_status: Option<&'a str>,
21}
22
23impl<'a> Widget for MatchDisplay<'a> {
24    fn render(self, area: Rect, buf: &mut Buffer) {
25        let title = if self.replace_result.is_some() {
26            format!(" Matches ({}) | Preview ", self.matches.len())
27        } else {
28            format!(" Matches ({}) ", self.matches.len())
29        };
30        let border_color = if self.focused {
31            theme::BLUE
32        } else {
33            theme::OVERLAY
34        };
35        let block = Block::default()
36            .borders(Borders::ALL)
37            .border_style(Style::default().fg(border_color))
38            .title(Span::styled(title, Style::default().fg(theme::TEXT)));
39
40        if self.matches.is_empty() {
41            let paragraph = Paragraph::new(Line::from(Span::styled(
42                "No matches",
43                Style::default().fg(theme::SUBTEXT),
44            )))
45            .block(block)
46            .style(Style::default().bg(theme::BASE));
47            paragraph.render(area, buf);
48            return;
49        }
50
51        let is_selected_panel = self.focused;
52        let mut lines = Vec::new();
53        for (i, m) in self.matches.iter().enumerate() {
54            let match_selected =
55                is_selected_panel && i == self.selected_match && self.selected_capture.is_none();
56            let prefix = if match_selected { ">> " } else { "   " };
57            let bg = if match_selected {
58                theme::SURFACE1
59            } else {
60                theme::BASE
61            };
62            lines.push(Line::from(vec![
63                Span::styled(prefix, Style::default().fg(theme::BLUE).bg(bg)),
64                Span::styled(
65                    format!("Match {} ", i + 1),
66                    Style::default().fg(theme::BLUE).bg(bg),
67                ),
68                Span::styled(
69                    format!("[{}-{}]: ", m.start, m.end),
70                    Style::default().fg(theme::SUBTEXT).bg(bg),
71                ),
72                Span::styled(
73                    format!("\"{}\"", &m.text),
74                    Style::default().fg(theme::GREEN).bg(bg),
75                ),
76            ]));
77
78            for (ci, cap) in m.captures.iter().enumerate() {
79                let cap_selected = is_selected_panel
80                    && i == self.selected_match
81                    && self.selected_capture == Some(ci);
82                let prefix = if cap_selected { ">> " } else { "   " };
83                let bg = if cap_selected {
84                    theme::SURFACE1
85                } else {
86                    theme::BASE
87                };
88                let color = theme::capture_color(cap.index.saturating_sub(1));
89                let name_str = cap
90                    .name
91                    .as_ref()
92                    .map(|n| format!(" '{n}'"))
93                    .unwrap_or_default();
94                lines.push(Line::from(vec![
95                    Span::styled(prefix, Style::default().fg(color).bg(bg)),
96                    Span::styled(
97                        format!("Group #{}{name_str} ", cap.index),
98                        Style::default().fg(color).bg(bg),
99                    ),
100                    Span::styled(
101                        format!("[{}-{}]: ", cap.start, cap.end),
102                        Style::default().fg(theme::SUBTEXT).bg(bg),
103                    ),
104                    Span::styled(
105                        format!("\"{}\"", &cap.text),
106                        Style::default().fg(color).bg(bg),
107                    ),
108                ]));
109            }
110        }
111
112        // Replace preview section
113        if let Some(result) = self.replace_result {
114            lines.push(Line::from(Span::styled(
115                "─── Replace Preview ───",
116                Style::default().fg(theme::OVERLAY),
117            )));
118
119            let preview_spans = build_replace_preview_spans(result);
120            let preview_lines = split_spans_into_lines(preview_spans);
121            lines.extend(preview_lines);
122        }
123
124        // Clipboard status
125        if let Some(status) = self.clipboard_status {
126            lines.push(Line::from(Span::styled(
127                status.to_string(),
128                Style::default().fg(theme::YELLOW),
129            )));
130        }
131
132        let paragraph = Paragraph::new(lines)
133            .block(block)
134            .style(Style::default().bg(theme::BASE))
135            .wrap(Wrap { trim: false })
136            .scroll((self.scroll, 0));
137
138        paragraph.render(area, buf);
139    }
140}
141
142fn build_replace_preview_spans(result: &engine::ReplaceResult) -> Vec<Span<'_>> {
143    let mut spans = Vec::new();
144    for seg in &result.segments {
145        let text = &result.output[seg.start..seg.end];
146        let style = if seg.is_replacement {
147            Style::default()
148                .fg(theme::BASE)
149                .bg(theme::GREEN)
150                .add_modifier(Modifier::BOLD)
151        } else {
152            Style::default().fg(theme::TEXT)
153        };
154        spans.push(Span::styled(text.to_string(), style));
155    }
156    if spans.is_empty() {
157        spans.push(Span::styled("(empty)", Style::default().fg(theme::SUBTEXT)));
158    }
159    spans
160}