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