Skip to main content

rgx/ui/
test_input.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::Rect,
4    style::{Modifier, Style},
5    text::{Line, Span},
6    widgets::{Block, Borders, Paragraph, Widget},
7};
8
9use crate::engine;
10use crate::input::editor::Editor;
11use crate::ui::theme;
12
13pub struct TestInput<'a> {
14    pub editor: &'a Editor,
15    pub focused: bool,
16    pub matches: &'a [engine::Match],
17}
18
19impl<'a> Widget for TestInput<'a> {
20    fn render(self, area: Rect, buf: &mut Buffer) {
21        let border_style = if self.focused {
22            Style::default().fg(theme::BLUE)
23        } else {
24            Style::default().fg(theme::OVERLAY)
25        };
26
27        let block = Block::default()
28            .borders(Borders::ALL)
29            .border_style(border_style)
30            .title(Span::styled(
31                " Test String ",
32                Style::default().fg(theme::TEXT),
33            ));
34
35        let content = self.editor.content();
36        let flat_spans = build_highlighted_spans(content, self.matches);
37        let lines = split_spans_into_lines(flat_spans);
38
39        // Apply vertical scroll
40        let v_scroll = self.editor.vertical_scroll();
41        let inner_height = (area.height as usize).saturating_sub(2); // borders
42        let visible_lines: Vec<Line> = lines
43            .into_iter()
44            .skip(v_scroll)
45            .take(inner_height)
46            .collect();
47
48        let paragraph = Paragraph::new(visible_lines)
49            .block(block)
50            .style(Style::default().bg(theme::BASE));
51
52        paragraph.render(area, buf);
53
54        // Render cursor
55        if self.focused {
56            let (cursor_line, cursor_col) = self.editor.cursor_line_col();
57            let visual_col = cursor_col.saturating_sub(self.editor.scroll_offset());
58            let visual_row = cursor_line.saturating_sub(v_scroll);
59            let cursor_x = area.x + 1 + visual_col as u16;
60            let cursor_y = area.y + 1 + visual_row as u16;
61            if cursor_x < area.x + area.width - 1 && cursor_y < area.y + area.height - 1 {
62                if let Some(cell) = buf.cell_mut((cursor_x, cursor_y)) {
63                    cell.set_style(
64                        Style::default()
65                            .fg(theme::BASE)
66                            .bg(theme::TEXT)
67                            .add_modifier(Modifier::BOLD),
68                    );
69                }
70            }
71        }
72    }
73}
74
75/// Split a flat list of spans at newline characters into multiple Lines.
76pub fn split_spans_into_lines<'a>(spans: Vec<Span<'a>>) -> Vec<Line<'a>> {
77    let mut lines: Vec<Line<'a>> = Vec::new();
78    let mut current_spans: Vec<Span<'a>> = Vec::new();
79
80    for span in spans {
81        let style = span.style;
82        let text: &str = span.content.as_ref();
83
84        let mut remaining = text;
85        while let Some(nl_pos) = remaining.find('\n') {
86            let before = &remaining[..nl_pos];
87            if !before.is_empty() {
88                current_spans.push(Span::styled(before.to_string(), style));
89            }
90            lines.push(Line::from(std::mem::take(&mut current_spans)));
91            remaining = &remaining[nl_pos + 1..];
92        }
93        if !remaining.is_empty() {
94            current_spans.push(Span::styled(remaining.to_string(), style));
95        }
96    }
97
98    // Final line (even if empty — this ensures we always have at least one line)
99    lines.push(Line::from(current_spans));
100    lines
101}
102
103fn build_highlighted_spans<'a>(text: &'a str, matches: &[engine::Match]) -> Vec<Span<'a>> {
104    if matches.is_empty() || text.is_empty() {
105        return vec![Span::styled(text, Style::default().fg(theme::TEXT))];
106    }
107
108    let mut spans = Vec::new();
109    let mut pos = 0;
110
111    for m in matches {
112        if m.start > pos {
113            spans.push(Span::styled(
114                &text[pos..m.start],
115                Style::default().fg(theme::TEXT),
116            ));
117        }
118
119        if m.captures.is_empty() {
120            spans.push(Span::styled(
121                &text[m.start..m.end],
122                Style::default()
123                    .fg(theme::TEXT)
124                    .bg(theme::MATCH_BG)
125                    .add_modifier(Modifier::BOLD),
126            ));
127        } else {
128            // Render with capture group colors
129            let mut inner_pos = m.start;
130            for cap in &m.captures {
131                if cap.start > inner_pos {
132                    spans.push(Span::styled(
133                        &text[inner_pos..cap.start],
134                        Style::default()
135                            .fg(theme::TEXT)
136                            .bg(theme::MATCH_BG)
137                            .add_modifier(Modifier::BOLD),
138                    ));
139                }
140                let color = theme::capture_color(cap.index.saturating_sub(1));
141                spans.push(Span::styled(
142                    &text[cap.start..cap.end],
143                    Style::default()
144                        .fg(theme::BASE)
145                        .bg(color)
146                        .add_modifier(Modifier::BOLD),
147                ));
148                inner_pos = cap.end;
149            }
150            if inner_pos < m.end {
151                spans.push(Span::styled(
152                    &text[inner_pos..m.end],
153                    Style::default()
154                        .fg(theme::TEXT)
155                        .bg(theme::MATCH_BG)
156                        .add_modifier(Modifier::BOLD),
157                ));
158            }
159        }
160
161        pos = m.end;
162    }
163
164    if pos < text.len() {
165        spans.push(Span::styled(&text[pos..], Style::default().fg(theme::TEXT)));
166    }
167
168    spans
169}