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 m in matches {
161        if m.start > pos {
162            spans.push(Span::styled(
163                &text[pos..m.start],
164                Style::default().fg(theme::TEXT),
165            ));
166        }
167
168        if m.captures.is_empty() {
169            spans.push(Span::styled(
170                &text[m.start..m.end],
171                Style::default()
172                    .fg(theme::TEXT)
173                    .bg(theme::MATCH_BG)
174                    .add_modifier(Modifier::BOLD),
175            ));
176        } else {
177            // Render with capture group colors
178            let mut inner_pos = m.start;
179            for cap in &m.captures {
180                if cap.start > inner_pos {
181                    spans.push(Span::styled(
182                        &text[inner_pos..cap.start],
183                        Style::default()
184                            .fg(theme::TEXT)
185                            .bg(theme::MATCH_BG)
186                            .add_modifier(Modifier::BOLD),
187                    ));
188                }
189                let color = theme::capture_color(cap.index.saturating_sub(1));
190                spans.push(Span::styled(
191                    &text[cap.start..cap.end],
192                    Style::default()
193                        .fg(theme::BASE)
194                        .bg(color)
195                        .add_modifier(Modifier::BOLD),
196                ));
197                inner_pos = cap.end;
198            }
199            if inner_pos < m.end {
200                spans.push(Span::styled(
201                    &text[inner_pos..m.end],
202                    Style::default()
203                        .fg(theme::TEXT)
204                        .bg(theme::MATCH_BG)
205                        .add_modifier(Modifier::BOLD),
206                ));
207            }
208        }
209
210        pos = m.end;
211    }
212
213    if pos < text.len() {
214        spans.push(Span::styled(&text[pos..], Style::default().fg(theme::TEXT)));
215    }
216
217    spans
218}