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    pub show_whitespace: bool,
18}
19
20impl<'a> Widget for TestInput<'a> {
21    fn render(self, area: Rect, buf: &mut Buffer) {
22        let border_style = if self.focused {
23            Style::default().fg(theme::BLUE)
24        } else {
25            Style::default().fg(theme::OVERLAY)
26        };
27
28        let block = Block::default()
29            .borders(Borders::ALL)
30            .border_style(border_style)
31            .title(Span::styled(
32                " Test String ",
33                Style::default().fg(theme::TEXT),
34            ));
35
36        let content = self.editor.content();
37        let flat_spans = build_highlighted_spans(content, self.matches);
38        let flat_spans = if self.show_whitespace {
39            visualize_whitespace(flat_spans)
40        } else {
41            flat_spans
42        };
43        let lines = split_spans_into_lines(flat_spans);
44
45        // Apply vertical scroll
46        let v_scroll = self.editor.vertical_scroll();
47        let inner_height = (area.height as usize).saturating_sub(2); // borders
48        let visible_lines: Vec<Line> = lines
49            .into_iter()
50            .skip(v_scroll)
51            .take(inner_height)
52            .collect();
53
54        let paragraph = Paragraph::new(visible_lines)
55            .block(block)
56            .style(Style::default().bg(theme::BASE));
57
58        paragraph.render(area, buf);
59
60        // Render cursor
61        if self.focused {
62            let (cursor_line, cursor_col) = self.editor.cursor_line_col();
63            let visual_col = cursor_col.saturating_sub(self.editor.scroll_offset());
64            let visual_row = cursor_line.saturating_sub(v_scroll);
65            let cursor_x = area.x + 1 + visual_col as u16;
66            let cursor_y = area.y + 1 + visual_row as u16;
67            if cursor_x < area.x + area.width - 1 && cursor_y < area.y + area.height - 1 {
68                if let Some(cell) = buf.cell_mut((cursor_x, cursor_y)) {
69                    cell.set_style(
70                        Style::default()
71                            .fg(theme::BASE)
72                            .bg(theme::TEXT)
73                            .add_modifier(Modifier::BOLD),
74                    );
75                }
76            }
77        }
78    }
79}
80
81/// Split a flat list of spans at newline characters into multiple Lines.
82pub fn split_spans_into_lines<'a>(spans: Vec<Span<'a>>) -> Vec<Line<'a>> {
83    let mut lines: Vec<Line<'a>> = Vec::new();
84    let mut current_spans: Vec<Span<'a>> = Vec::new();
85
86    for span in spans {
87        let style = span.style;
88        let text: &str = span.content.as_ref();
89
90        let mut remaining = text;
91        while let Some(nl_pos) = remaining.find('\n') {
92            let before = &remaining[..nl_pos];
93            if !before.is_empty() {
94                current_spans.push(Span::styled(before.to_string(), style));
95            }
96            lines.push(Line::from(std::mem::take(&mut current_spans)));
97            remaining = &remaining[nl_pos + 1..];
98        }
99        if !remaining.is_empty() {
100            current_spans.push(Span::styled(remaining.to_string(), style));
101        }
102    }
103
104    // Final line (even if empty — this ensures we always have at least one line)
105    lines.push(Line::from(current_spans));
106    lines
107}
108
109/// Replace spaces with `·` and insert `↵` before newlines for whitespace visualization.
110fn visualize_whitespace<'a>(spans: Vec<Span<'a>>) -> Vec<Span<'a>> {
111    let mut result = Vec::new();
112    for span in spans {
113        let style = span.style;
114        let text: &str = span.content.as_ref();
115        let ws_style = Style::default().fg(theme::OVERLAY);
116        let mut segment = String::new();
117
118        for c in text.chars() {
119            match c {
120                ' ' => {
121                    if !segment.is_empty() {
122                        result.push(Span::styled(std::mem::take(&mut segment), style));
123                    }
124                    result.push(Span::styled("\u{00b7}", ws_style)); // ·
125                }
126                '\n' => {
127                    if !segment.is_empty() {
128                        result.push(Span::styled(std::mem::take(&mut segment), style));
129                    }
130                    result.push(Span::styled("\u{21b5}\n", ws_style)); // ↵ + actual newline
131                }
132                '\t' => {
133                    if !segment.is_empty() {
134                        result.push(Span::styled(std::mem::take(&mut segment), style));
135                    }
136                    result.push(Span::styled("\u{2192}", ws_style)); // →
137                }
138                _ => {
139                    segment.push(c);
140                }
141            }
142        }
143        if !segment.is_empty() {
144            result.push(Span::styled(segment, style));
145        }
146    }
147    result
148}
149
150fn build_highlighted_spans<'a>(text: &'a str, matches: &[engine::Match]) -> Vec<Span<'a>> {
151    if matches.is_empty() || text.is_empty() {
152        return vec![Span::styled(text, Style::default().fg(theme::TEXT))];
153    }
154
155    let mut spans = Vec::new();
156    let mut pos = 0;
157
158    for m in matches {
159        if m.start > pos {
160            spans.push(Span::styled(
161                &text[pos..m.start],
162                Style::default().fg(theme::TEXT),
163            ));
164        }
165
166        if m.captures.is_empty() {
167            spans.push(Span::styled(
168                &text[m.start..m.end],
169                Style::default()
170                    .fg(theme::TEXT)
171                    .bg(theme::MATCH_BG)
172                    .add_modifier(Modifier::BOLD),
173            ));
174        } else {
175            // Render with capture group colors
176            let mut inner_pos = m.start;
177            for cap in &m.captures {
178                if cap.start > inner_pos {
179                    spans.push(Span::styled(
180                        &text[inner_pos..cap.start],
181                        Style::default()
182                            .fg(theme::TEXT)
183                            .bg(theme::MATCH_BG)
184                            .add_modifier(Modifier::BOLD),
185                    ));
186                }
187                let color = theme::capture_color(cap.index.saturating_sub(1));
188                spans.push(Span::styled(
189                    &text[cap.start..cap.end],
190                    Style::default()
191                        .fg(theme::BASE)
192                        .bg(color)
193                        .add_modifier(Modifier::BOLD),
194                ));
195                inner_pos = cap.end;
196            }
197            if inner_pos < m.end {
198                spans.push(Span::styled(
199                    &text[inner_pos..m.end],
200                    Style::default()
201                        .fg(theme::TEXT)
202                        .bg(theme::MATCH_BG)
203                        .add_modifier(Modifier::BOLD),
204                ));
205            }
206        }
207
208        pos = m.end;
209    }
210
211    if pos < text.len() {
212        spans.push(Span::styled(&text[pos..], Style::default().fg(theme::TEXT)));
213    }
214
215    spans
216}