flake_edit/tui/components/input/
view.rs1use 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
18struct 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) .unwrap_or(0);
44 item.text.len() + desc_len
45 })
46 .max()
47 .unwrap_or(0);
48 (max_len + 3) as u16 }
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 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 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 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 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
129pub 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 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 pub fn required_height(&self) -> u16 {
165 4 }
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 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 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 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}