flake_edit/tui/components/input/
view.rs1use ratatui::{
2 buffer::Buffer,
3 layout::Rect,
4 style::{Color, Modifier},
5 text::{Line, Span},
6 widgets::{Block, Borders, Paragraph, Widget},
7};
8
9use std::collections::HashSet;
10
11use super::model::{CompletionItem, InputState, MAX_VISIBLE_COMPLETIONS};
12use crate::tui::components::footer::Footer;
13use crate::tui::helpers::{context_span, diff_toggle_style, layouts};
14use crate::tui::style::{
15 BORDER_STYLE, COMPLETION_MATCH_STYLE, COMPLETION_SELECTED_MATCH_STYLE, DIMMED_STYLE,
16 FOOTER_STYLE, HIGHLIGHT_COLOR, INPUT_PROMPT, LABEL_STYLE_INVERSE, PLACEHOLDER_STYLE,
17};
18
19struct Completion<'a> {
21 items: &'a [CompletionItem],
22 selected: Option<usize>,
23 anchor_x: u16,
24}
25
26impl<'a> Completion<'a> {
27 fn new(items: &'a [CompletionItem], selected: Option<usize>, anchor_x: u16) -> Self {
28 Self {
29 items,
30 selected,
31 anchor_x,
32 }
33 }
34
35 fn width(&self) -> u16 {
36 let max_len = self
37 .items
38 .iter()
39 .map(|item| {
40 let desc_len = item
41 .description
42 .as_ref()
43 .map(|d| d.chars().count() + 3) .unwrap_or(0);
45 item.text.chars().count() + desc_len
46 })
47 .max()
48 .unwrap_or(0);
49 (max_len + 3) as u16 }
51}
52
53impl Widget for Completion<'_> {
54 fn render(self, area: Rect, buf: &mut Buffer) {
55 if self.items.is_empty() {
56 return;
57 }
58
59 let width = self.width();
60 let max_x = area.x + area.width;
61 let items_to_show = self.items.len().min(MAX_VISIBLE_COMPLETIONS);
62
63 for (i, item) in self.items.iter().take(items_to_show).enumerate() {
64 let y = area.y + i as u16;
65 let is_selected = Some(i) == self.selected;
66
67 let (base_style, match_style) = if is_selected {
68 (
69 FOOTER_STYLE
70 .fg(HIGHLIGHT_COLOR)
71 .add_modifier(Modifier::BOLD),
72 COMPLETION_SELECTED_MATCH_STYLE,
73 )
74 } else {
75 (FOOTER_STYLE, COMPLETION_MATCH_STYLE)
76 };
77 let desc_style = FOOTER_STYLE.fg(DIMMED_STYLE.fg.unwrap_or(Color::DarkGray));
78
79 let line_start = self.anchor_x;
80 let line_end = (self.anchor_x + width).min(max_x);
81
82 for x in line_start..line_end {
83 if let Some(cell) = buf.cell_mut((x, y)) {
84 cell.reset();
85 cell.set_char(' ');
86 cell.set_style(base_style);
87 }
88 }
89
90 let match_set: HashSet<u32> = item.match_indices.iter().copied().collect();
91 let mut x = line_start + 1;
92
93 for (char_idx, ch) in item.text.chars().enumerate() {
95 if x >= line_end {
96 break;
97 }
98 if let Some(cell) = buf.cell_mut((x, y)) {
99 cell.reset();
100 cell.set_char(ch);
101 let style = if match_set.contains(&(char_idx as u32)) {
102 match_style
103 } else {
104 base_style
105 };
106 cell.set_style(style);
107 }
108 x += 1;
109 }
110
111 if let Some(desc) = &item.description {
113 for ch in " · ".chars().chain(desc.chars()) {
114 if x >= line_end {
115 break;
116 }
117 if let Some(cell) = buf.cell_mut((x, y)) {
118 cell.reset();
119 cell.set_char(ch);
120 cell.set_style(desc_style);
121 }
122 x += 1;
123 }
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}