flake_edit/tui/components/list/
view.rs1use ratatui::{
2 buffer::Buffer,
3 layout::Rect,
4 style::{Color, Style},
5 text::{Line, Span},
6 widgets::{Block, Borders, List as RatatuiList, ListItem, ListState, StatefulWidget, Widget},
7};
8
9use super::model::ListState as SelectionState;
10use crate::tui::components::footer::Footer;
11use crate::tui::helpers::{checkbox_line, context_span, diff_toggle_style, layouts};
12use crate::tui::style::{BORDER_STYLE, HIGHLIGHT_STYLE, HIGHLIGHT_SYMBOL};
13
14fn parse_follows_item(item: &str) -> (&str, Option<&str>) {
18 if let Some((path, follows)) = item.split_once('\t') {
19 (path, Some(follows))
20 } else {
21 (item, None)
22 }
23}
24
25fn styled_item_line(item: &str) -> Line<'_> {
27 let (path, follows) = parse_follows_item(item);
28 if let Some(target) = follows {
29 Line::from(vec![
30 Span::raw(path),
31 Span::styled(" ยท ", Style::default().fg(Color::DarkGray)),
32 Span::styled(target, Style::default().fg(Color::DarkGray)),
33 ])
34 } else {
35 Line::raw(path)
36 }
37}
38
39pub struct List<'a> {
41 state: &'a SelectionState,
42 prompt: &'a str,
43 context: &'a str,
44}
45
46impl<'a> List<'a> {
47 pub fn new(state: &'a SelectionState, prompt: &'a str, context: &'a str) -> Self {
48 Self {
49 state,
50 prompt,
51 context,
52 }
53 }
54}
55
56impl Widget for List<'_> {
57 fn render(self, area: Rect, buf: &mut Buffer) {
58 let (content_area, footer_area) = layouts::content_with_footer(area);
59
60 let mut list_state = ListState::default();
61 list_state.select(Some(self.state.cursor()));
62
63 let list_items: Vec<ListItem> = if self.state.multi_select() {
64 self.state
65 .visible_items()
66 .map(|(i, item)| ListItem::new(checkbox_line(item, self.state.is_selected(i))))
67 .collect()
68 } else {
69 self.state
70 .visible_items()
71 .map(|(_, item)| ListItem::new(styled_item_line(item)))
72 .collect()
73 };
74
75 let mut list = RatatuiList::new(list_items)
76 .block(
77 Block::default()
78 .borders(Borders::TOP | Borders::BOTTOM)
79 .border_style(BORDER_STYLE),
80 )
81 .highlight_symbol(HIGHLIGHT_SYMBOL);
82
83 if !self.state.multi_select() {
85 list = list.highlight_style(HIGHLIGHT_STYLE);
86 }
87
88 StatefulWidget::render(list, content_area, buf, &mut list_state);
89
90 let count_info = if self.state.multi_select() && self.state.selected_count() > 0 {
92 format!(" ({} selected)", self.state.selected_count())
93 } else {
94 String::new()
95 };
96
97 let mode_span = match self.state.search_query() {
98 Some(query) => Span::styled(format!(" /{query}"), HIGHLIGHT_STYLE),
99 None => Span::raw(format!(" {}", self.prompt)),
100 };
101
102 let (diff_label, diff_style) = diff_toggle_style(self.state.show_diff());
103 Footer::new(
104 vec![context_span(self.context), mode_span, Span::raw(count_info)],
105 vec![Span::styled(format!(" {} ", diff_label), diff_style)],
106 )
107 .render(footer_area, buf);
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use crate::tui::components::list::ListAction;
115 use ratatui::{Terminal, backend::TestBackend};
116
117 fn create_test_terminal(width: u16, height: u16) -> Terminal<TestBackend> {
118 let backend = TestBackend::new(width, height);
119 Terminal::new(backend).unwrap()
120 }
121
122 fn buffer_to_plain_text(terminal: &Terminal<TestBackend>) -> String {
123 let buffer = terminal.backend().buffer();
124 let mut lines = Vec::new();
125 for y in 0..buffer.area.height {
126 let mut line = String::new();
127 for x in 0..buffer.area.width {
128 line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' '));
129 }
130 lines.push(line.trim_end().to_string());
131 }
132 while lines.last().is_some_and(|l| l.is_empty()) {
133 lines.pop();
134 }
135 lines.join("\n")
136 }
137
138 #[test]
139 fn test_render_single_select() {
140 let mut terminal = create_test_terminal(80, 8);
141 let items = vec![
142 "nixpkgs".to_string(),
143 "home-manager".to_string(),
144 "flake-utils".to_string(),
145 ];
146 let state = SelectionState::new(items, false, false);
147
148 terminal
149 .draw(|frame| {
150 List::new(&state, "Select input", "Change")
151 .render(frame.area(), frame.buffer_mut());
152 })
153 .unwrap();
154
155 let output = buffer_to_plain_text(&terminal);
156 insta::assert_snapshot!(output);
157 }
158
159 #[test]
160 fn test_render_single_select_with_diff_on() {
161 let mut terminal = create_test_terminal(80, 8);
162 let items = vec!["nixpkgs".to_string(), "home-manager".to_string()];
163 let mut state = SelectionState::new(items, false, true);
164 state.handle(ListAction::Down);
165
166 terminal
167 .draw(|frame| {
168 List::new(&state, "Select input", "Change")
169 .render(frame.area(), frame.buffer_mut());
170 })
171 .unwrap();
172
173 let output = buffer_to_plain_text(&terminal);
174 insta::assert_snapshot!(output);
175 }
176
177 #[test]
178 fn test_render_multi_select() {
179 let mut terminal = create_test_terminal(80, 8);
180 let items = vec![
181 "nixpkgs".to_string(),
182 "home-manager".to_string(),
183 "flake-utils".to_string(),
184 ];
185 let mut state = SelectionState::new(items, true, false);
186 state.handle(ListAction::Toggle);
187
188 terminal
189 .draw(|frame| {
190 List::new(&state, "Select inputs", "Update")
191 .render(frame.area(), frame.buffer_mut());
192 })
193 .unwrap();
194
195 let output = buffer_to_plain_text(&terminal);
196 insta::assert_snapshot!(output);
197 }
198
199 fn search_items() -> Vec<String> {
200 [
201 "bookah",
202 "centerpiece",
203 "clan",
204 "crane",
205 "depot",
206 "direnv-instant",
207 "disko",
208 "distro",
209 "flake-fmt",
210 ]
211 .iter()
212 .map(|s| s.to_string())
213 .collect()
214 }
215
216 #[test]
217 fn test_render_single_select_search_filtered() {
218 let mut terminal = create_test_terminal(80, 8);
219 let mut state = SelectionState::new(search_items(), false, false);
220 state.handle(ListAction::SearchStart);
221 state.handle(ListAction::SearchInput('f'));
222 state.handle(ListAction::SearchInput('l'));
223
224 terminal
225 .draw(|frame| {
226 List::new(&state, "Select input", "Change")
227 .render(frame.area(), frame.buffer_mut());
228 })
229 .unwrap();
230
231 let output = buffer_to_plain_text(&terminal);
232 insta::assert_snapshot!(output);
233 }
234
235 #[test]
236 fn test_render_single_select_search_no_match() {
237 let mut terminal = create_test_terminal(80, 8);
238 let mut state = SelectionState::new(search_items(), false, false);
239 state.handle(ListAction::SearchStart);
240 for c in "zzz".chars() {
241 state.handle(ListAction::SearchInput(c));
242 }
243
244 terminal
245 .draw(|frame| {
246 List::new(&state, "Select input", "Change")
247 .render(frame.area(), frame.buffer_mut());
248 })
249 .unwrap();
250
251 let output = buffer_to_plain_text(&terminal);
252 insta::assert_snapshot!(output);
253 }
254
255 #[test]
256 fn test_render_multi_select_search_keeps_selection() {
257 let mut terminal = create_test_terminal(80, 8);
258 let mut state = SelectionState::new(search_items(), true, false);
259 state.handle(ListAction::Toggle);
261 state.handle(ListAction::SearchStart);
262 state.handle(ListAction::SearchInput('f'));
263 state.handle(ListAction::SearchInput('l'));
264 state.handle(ListAction::Toggle);
265
266 terminal
267 .draw(|frame| {
268 List::new(&state, "Select inputs", "Update")
269 .render(frame.area(), frame.buffer_mut());
270 })
271 .unwrap();
272
273 let output = buffer_to_plain_text(&terminal);
274 insta::assert_snapshot!(output);
275 }
276
277 #[test]
278 fn test_parse_follows_item_with_target() {
279 let (path, follows) = parse_follows_item("crane.nixpkgs\tnixpkgs");
280 assert_eq!(path, "crane.nixpkgs");
281 assert_eq!(follows, Some("nixpkgs"));
282 }
283
284 #[test]
285 fn test_parse_follows_item_without_target() {
286 let (path, follows) = parse_follows_item("crane.nixpkgs");
287 assert_eq!(path, "crane.nixpkgs");
288 assert_eq!(follows, None);
289 }
290
291 #[test]
292 fn test_nested_input_display_roundtrip() {
293 use crate::follows::AttrPath;
294 use crate::lock::NestedInput;
295
296 let input = NestedInput {
298 path: AttrPath::parse("crane.nixpkgs").unwrap(),
299 follows: Some(AttrPath::parse("nixpkgs").unwrap()),
300 url: None,
301 };
302 let display = input.to_display_string();
303 let (path, follows) = parse_follows_item(&display);
304 assert_eq!(path, "crane.nixpkgs");
305 assert_eq!(follows, Some("nixpkgs"));
306
307 let input = NestedInput {
309 path: AttrPath::parse("crane.nixpkgs").unwrap(),
310 follows: None,
311 url: None,
312 };
313 let display = input.to_display_string();
314 let (path, follows) = parse_follows_item(&display);
315 assert_eq!(path, "crane.nixpkgs");
316 assert_eq!(follows, None);
317 }
318}