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 items: &'a [String],
43 prompt: &'a str,
44 context: &'a str,
45}
46
47impl<'a> List<'a> {
48 pub fn new(
49 state: &'a SelectionState,
50 items: &'a [String],
51 prompt: &'a str,
52 context: &'a str,
53 ) -> Self {
54 Self {
55 state,
56 items,
57 prompt,
58 context,
59 }
60 }
61}
62
63impl Widget for List<'_> {
64 fn render(self, area: Rect, buf: &mut Buffer) {
65 let (content_area, footer_area) = layouts::content_with_footer(area);
66
67 let mut list_state = ListState::default();
68 list_state.select(Some(self.state.cursor()));
69
70 let list_items: Vec<ListItem> = if self.state.multi_select() {
71 self.items
72 .iter()
73 .enumerate()
74 .map(|(i, item)| ListItem::new(checkbox_line(item, self.state.is_selected(i))))
75 .collect()
76 } else {
77 self.items
78 .iter()
79 .map(|item| ListItem::new(styled_item_line(item)))
80 .collect()
81 };
82
83 let mut list = RatatuiList::new(list_items)
84 .block(
85 Block::default()
86 .borders(Borders::TOP | Borders::BOTTOM)
87 .border_style(BORDER_STYLE),
88 )
89 .highlight_symbol(HIGHLIGHT_SYMBOL);
90
91 if !self.state.multi_select() {
93 list = list.highlight_style(HIGHLIGHT_STYLE);
94 }
95
96 StatefulWidget::render(list, content_area, buf, &mut list_state);
97
98 let count_info = if self.state.multi_select() && self.state.selected_count() > 0 {
100 format!(" ({} selected)", self.state.selected_count())
101 } else {
102 String::new()
103 };
104
105 let (diff_label, diff_style) = diff_toggle_style(self.state.show_diff());
106 Footer::new(
107 vec![
108 context_span(self.context),
109 Span::raw(format!(" {}{}", self.prompt, count_info)),
110 ],
111 vec![Span::styled(format!(" {} ", diff_label), diff_style)],
112 )
113 .render(footer_area, buf);
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use crate::tui::components::list::ListAction;
121 use ratatui::{Terminal, backend::TestBackend};
122
123 fn create_test_terminal(width: u16, height: u16) -> Terminal<TestBackend> {
124 let backend = TestBackend::new(width, height);
125 Terminal::new(backend).unwrap()
126 }
127
128 fn buffer_to_plain_text(terminal: &Terminal<TestBackend>) -> String {
129 let buffer = terminal.backend().buffer();
130 let mut lines = Vec::new();
131 for y in 0..buffer.area.height {
132 let mut line = String::new();
133 for x in 0..buffer.area.width {
134 line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' '));
135 }
136 lines.push(line.trim_end().to_string());
137 }
138 while lines.last().is_some_and(|l| l.is_empty()) {
139 lines.pop();
140 }
141 lines.join("\n")
142 }
143
144 #[test]
145 fn test_render_single_select() {
146 let mut terminal = create_test_terminal(80, 8);
147 let items = vec![
148 "nixpkgs".to_string(),
149 "home-manager".to_string(),
150 "flake-utils".to_string(),
151 ];
152 let state = SelectionState::new(items.len(), false, false);
153
154 terminal
155 .draw(|frame| {
156 List::new(&state, &items, "Select input", "Change")
157 .render(frame.area(), frame.buffer_mut());
158 })
159 .unwrap();
160
161 let output = buffer_to_plain_text(&terminal);
162 insta::assert_snapshot!(output);
163 }
164
165 #[test]
166 fn test_render_single_select_with_diff_on() {
167 let mut terminal = create_test_terminal(80, 8);
168 let items = vec!["nixpkgs".to_string(), "home-manager".to_string()];
169 let mut state = SelectionState::new(items.len(), false, true);
170 state.handle(ListAction::Down);
171
172 terminal
173 .draw(|frame| {
174 List::new(&state, &items, "Select input", "Change")
175 .render(frame.area(), frame.buffer_mut());
176 })
177 .unwrap();
178
179 let output = buffer_to_plain_text(&terminal);
180 insta::assert_snapshot!(output);
181 }
182
183 #[test]
184 fn test_render_multi_select() {
185 let mut terminal = create_test_terminal(80, 8);
186 let items = vec![
187 "nixpkgs".to_string(),
188 "home-manager".to_string(),
189 "flake-utils".to_string(),
190 ];
191 let mut state = SelectionState::new(items.len(), true, false);
192 state.handle(ListAction::Toggle);
193
194 terminal
195 .draw(|frame| {
196 List::new(&state, &items, "Select inputs", "Update")
197 .render(frame.area(), frame.buffer_mut());
198 })
199 .unwrap();
200
201 let output = buffer_to_plain_text(&terminal);
202 insta::assert_snapshot!(output);
203 }
204
205 #[test]
206 fn test_parse_follows_item_with_target() {
207 let (path, follows) = parse_follows_item("crane.nixpkgs\tnixpkgs");
208 assert_eq!(path, "crane.nixpkgs");
209 assert_eq!(follows, Some("nixpkgs"));
210 }
211
212 #[test]
213 fn test_parse_follows_item_without_target() {
214 let (path, follows) = parse_follows_item("crane.nixpkgs");
215 assert_eq!(path, "crane.nixpkgs");
216 assert_eq!(follows, None);
217 }
218
219 #[test]
220 fn test_nested_input_display_roundtrip() {
221 use crate::lock::NestedInput;
222
223 let input = NestedInput {
225 path: "crane.nixpkgs".into(),
226 follows: Some("nixpkgs".into()),
227 };
228 let display = input.to_display_string();
229 let (path, follows) = parse_follows_item(&display);
230 assert_eq!(path, "crane.nixpkgs");
231 assert_eq!(follows, Some("nixpkgs"));
232
233 let input = NestedInput {
235 path: "crane.nixpkgs".into(),
236 follows: None,
237 };
238 let display = input.to_display_string();
239 let (path, follows) = parse_follows_item(&display);
240 assert_eq!(path, "crane.nixpkgs");
241 assert_eq!(follows, None);
242 }
243}