flake_edit/tui/components/input/
view.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::Rect,
4    text::{Line, Span},
5    widgets::{Block, Borders, Paragraph, Widget},
6};
7
8use std::collections::HashSet;
9
10use super::model::{CompletionItem, InputState, MAX_VISIBLE_COMPLETIONS};
11use crate::tui::components::footer::Footer;
12use crate::tui::helpers::{context_span, diff_toggle_style, layouts};
13use crate::tui::style::{
14    BORDER_STYLE, COMPLETION_MATCH_STYLE, COMPLETION_SELECTED_MATCH_STYLE, DIMMED_STYLE,
15    FOOTER_STYLE, HIGHLIGHT_STYLE, INPUT_PROMPT, LABEL_STYLE_INVERSE, PLACEHOLDER_STYLE,
16};
17
18/// Completion dropdown overlay widget
19struct Completion<'a> {
20    items: &'a [CompletionItem],
21    selected: Option<usize>,
22    anchor_x: u16,
23}
24
25impl<'a> Completion<'a> {
26    fn new(items: &'a [CompletionItem], selected: Option<usize>, anchor_x: u16) -> Self {
27        Self {
28            items,
29            selected,
30            anchor_x,
31        }
32    }
33
34    fn width(&self) -> u16 {
35        let max_len = self
36            .items
37            .iter()
38            .map(|item| {
39                let desc_len = item
40                    .description
41                    .as_ref()
42                    .map(|d| d.len() + 3) // " · " separator
43                    .unwrap_or(0);
44                item.text.len() + desc_len
45            })
46            .max()
47            .unwrap_or(0);
48        (max_len + 3) as u16 // 1 leading + 2 trailing padding
49    }
50}
51
52impl Widget for Completion<'_> {
53    fn render(self, area: Rect, buf: &mut Buffer) {
54        if self.items.is_empty() {
55            return;
56        }
57
58        let width = self.width();
59        let max_x = area.x + area.width;
60        let items_to_show = self.items.len().min(MAX_VISIBLE_COMPLETIONS);
61
62        for (i, item) in self.items.iter().take(items_to_show).enumerate() {
63            let y = area.y + i as u16;
64            let is_selected = Some(i) == self.selected;
65
66            let (base_style, match_style) = if is_selected {
67                (HIGHLIGHT_STYLE, COMPLETION_SELECTED_MATCH_STYLE)
68            } else {
69                (FOOTER_STYLE, COMPLETION_MATCH_STYLE)
70            };
71
72            let match_set: HashSet<u32> = item.match_indices.iter().copied().collect();
73            let mut x = self.anchor_x;
74
75            // Leading padding
76            if let Some(cell) = buf.cell_mut((x, y)) {
77                cell.reset();
78                cell.set_char(' ');
79                cell.set_style(base_style);
80            }
81            x += 1;
82
83            // Completion text with match highlighting
84            for (char_idx, ch) in item.text.chars().enumerate() {
85                if x >= max_x || x >= self.anchor_x + width {
86                    break;
87                }
88                if let Some(cell) = buf.cell_mut((x, y)) {
89                    cell.reset();
90                    cell.set_char(ch);
91                    let style = if match_set.contains(&(char_idx as u32)) {
92                        match_style
93                    } else {
94                        base_style
95                    };
96                    cell.set_style(style);
97                }
98                x += 1;
99            }
100
101            // Description (dimmed)
102            if let Some(desc) = &item.description {
103                for ch in " · ".chars().chain(desc.chars()) {
104                    if x >= max_x {
105                        break;
106                    }
107                    if let Some(cell) = buf.cell_mut((x, y)) {
108                        cell.reset();
109                        cell.set_char(ch);
110                        cell.set_style(DIMMED_STYLE);
111                    }
112                    x += 1;
113                }
114            }
115
116            // Trailing padding
117            while x < (self.anchor_x + width).min(max_x) {
118                if let Some(cell) = buf.cell_mut((x, y)) {
119                    cell.reset();
120                    cell.set_char(' ');
121                    cell.set_style(base_style);
122                }
123                x += 1;
124            }
125        }
126    }
127}
128
129/// Input widget for text entry
130pub struct Input<'a> {
131    state: &'a InputState,
132    prompt: &'a str,
133    context: &'a str,
134    label: Option<&'a str>,
135    show_diff: bool,
136}
137
138impl<'a> Input<'a> {
139    pub fn new(
140        state: &'a InputState,
141        prompt: &'a str,
142        context: &'a str,
143        label: Option<&'a str>,
144        show_diff: bool,
145    ) -> Self {
146        Self {
147            state,
148            prompt,
149            context,
150            label,
151            show_diff,
152        }
153    }
154
155    /// Calculate cursor position for the given area
156    pub fn cursor_position(&self, area: Rect) -> (u16, u16) {
157        let (content_area, _) = layouts::fixed_content_with_footer(area, 3);
158        let cursor_x = content_area.x + 2 + self.state.cursor() as u16;
159        let cursor_y = content_area.y + 1;
160        (cursor_x, cursor_y)
161    }
162
163    /// Calculate required height (fixed - completions overlay the footer)
164    pub fn required_height(&self) -> u16 {
165        4 // 3 for bordered content + 1 for footer
166    }
167}
168
169impl Widget for Input<'_> {
170    fn render(self, area: Rect, buf: &mut Buffer) {
171        let (content_area, footer_area) = layouts::fixed_content_with_footer(area, 3);
172
173        // Render input box
174        let display_text = if self.state.is_empty() {
175            Line::from(vec![
176                Span::raw(INPUT_PROMPT),
177                Span::styled("Type here...", PLACEHOLDER_STYLE),
178            ])
179        } else {
180            Line::from(vec![Span::raw(INPUT_PROMPT), Span::raw(self.state.text())])
181        };
182        let content = Paragraph::new(display_text).block(
183            Block::default()
184                .borders(Borders::TOP | Borders::BOTTOM)
185                .border_style(BORDER_STYLE),
186        );
187        content.render(content_area, buf);
188
189        // Render footer
190        let mut footer_spans = vec![context_span(self.context)];
191        if let Some(lbl) = self.label {
192            footer_spans.push(Span::raw(" "));
193            footer_spans.push(Span::styled(format!(" {} ", lbl), LABEL_STYLE_INVERSE));
194        }
195        footer_spans.push(Span::raw(format!(" {}", self.prompt)));
196
197        let (diff_label, diff_style) = diff_toggle_style(self.show_diff);
198        Footer::new(
199            footer_spans,
200            vec![Span::styled(format!(" {} ", diff_label), diff_style)],
201        )
202        .render(footer_area, buf);
203
204        // Render completions overlay on border/footer area
205        if self.state.has_visible_completions() {
206            let anchor_x = content_area.x + 2 + self.state.completion_anchor() as u16;
207            let overlay_area = Rect {
208                x: area.x,
209                y: footer_area.y.saturating_sub(1),
210                width: area.width,
211                height: MAX_VISIBLE_COMPLETIONS as u16,
212            };
213            Completion::new(
214                self.state.filtered_completions(),
215                self.state.visible_selection_index(),
216                anchor_x,
217            )
218            .render(overlay_area, buf);
219        }
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use ratatui::{Terminal, backend::TestBackend};
227
228    fn create_test_terminal(width: u16, height: u16) -> Terminal<TestBackend> {
229        let backend = TestBackend::new(width, height);
230        Terminal::new(backend).unwrap()
231    }
232
233    fn buffer_to_plain_text(terminal: &Terminal<TestBackend>) -> String {
234        let buffer = terminal.backend().buffer();
235        let mut lines = Vec::new();
236        for y in 0..buffer.area.height {
237            let mut line = String::new();
238            for x in 0..buffer.area.width {
239                line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' '));
240            }
241            lines.push(line.trim_end().to_string());
242        }
243        while lines.last().is_some_and(|l| l.is_empty()) {
244            lines.pop();
245        }
246        lines.join("\n")
247    }
248
249    #[test]
250    fn test_render_input_empty() {
251        let mut terminal = create_test_terminal(80, 4);
252        let state = InputState::new(None);
253
254        terminal
255            .draw(|frame| {
256                Input::new(&state, "Enter URI", "Add", None, false)
257                    .render(frame.area(), frame.buffer_mut());
258            })
259            .unwrap();
260
261        let output = buffer_to_plain_text(&terminal);
262        insta::assert_snapshot!(output);
263    }
264
265    #[test]
266    fn test_render_input_with_text() {
267        let mut terminal = create_test_terminal(80, 4);
268        let state = InputState::new(Some("github:nixos/nixpkgs"));
269
270        terminal
271            .draw(|frame| {
272                Input::new(&state, "Enter URI", "Add", None, true)
273                    .render(frame.area(), frame.buffer_mut());
274            })
275            .unwrap();
276
277        let output = buffer_to_plain_text(&terminal);
278        insta::assert_snapshot!(output);
279    }
280
281    #[test]
282    fn test_render_input_with_label() {
283        let mut terminal = create_test_terminal(80, 4);
284        let state = InputState::new(Some("nixpkgs"));
285
286        terminal
287            .draw(|frame| {
288                Input::new(&state, "for github:nixos/nixpkgs", "Add", Some("ID"), false)
289                    .render(frame.area(), frame.buffer_mut());
290            })
291            .unwrap();
292
293        let output = buffer_to_plain_text(&terminal);
294        insta::assert_snapshot!(output);
295    }
296}